diff --git a/.env.example b/.env.example index 11b718792..b52726f20 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,22 @@ TESTNET_CORE_HOST=127.0.0.1 TESTNET_CORE_RPC_PORT=19998 TESTNET_CORE_RPC_USER=dashrpc TESTNET_CORE_RPC_PASSWORD=password -TESTNET_INSIGHT_API_URL=https://testnet-insight.dash.org/insight-api \ No newline at end of file +TESTNET_INSIGHT_API_URL=https://testnet-insight.dash.org/insight-api + +# Devnet configuration +DEVNET_SHOW_IN_UI=true +DEVNET_DAPI_ADDRESSES=https://34.214.48.68:1443 +DEVNET_CORE_HOST=127.0.0.1 +DEVNET_CORE_RPC_PORT=12998 +DEVNET_CORE_RPC_USER=dashrpc +DEVNET_CORE_RPC_PASSWORD=password +DEVNET_INSIGHT_API_URL= + +# Local network configuration +LOCAL_SHOW_IN_UI=true +LOCAL_DAPI_ADDRESSES=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0.1:2643 +LOCAL_CORE_HOST=127.0.0.1 +LOCAL_CORE_RPC_PORT=20302 +LOCAL_CORE_RPC_USER=dashmate +LOCAL_CORE_RPC_PASSWORD=08UxOLPtig8T +LOCAL_INSIGHT_API_URL=http://localhost:3001/insight-api \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 710fd8e5b..d1f409215 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: target: "x86_64-unknown-linux-gnu" platform: "x86_64-linux" - name: "linux-arm64" - runs-on: ["self-hosted", "Linux", "ARM64", "ubuntu20.04"] # Array of tags for ARM64 + runs-on: "ubuntu-22.04-arm" target: "aarch64-unknown-linux-gnu" platform: "arm64-linux" - name: "macos-x86_64" @@ -83,7 +83,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config clang cmake unzip libsqlite3-dev gcc-mingw-w64 mingw-w64 libsqlite3-dev mingw-w64-x86-64-dev gcc-aarch64-linux-gnu zip && uname -a && cargo clean - name: Install protoc (ARM) - if: ${{ matrix.platform == 'arm64' }} + if: ${{ matrix.platform == 'arm64-linux' }} run: curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-aarch_64.zip && sudo unzip -o protoc-25.2-linux-aarch_64.zip -d /usr/local bin/protoc && sudo unzip -o protoc-25.2-linux-aarch_64.zip -d /usr/local 'include/*' && rm -f protoc-25.2-linux-aarch_64.zip env: PROTOC: /usr/local/bin/protoc diff --git a/Cargo.toml b/Cargo.toml index ce4502654..e63df8f64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.9.0" license = "MIT" edition = "2021" default-run = "dash-evo-tool" -rust-version = "1.81" +rust-version = "1.85" build = "build.rs" [build] @@ -16,8 +16,9 @@ rustflags = [ bip39 = { version = "2.1.0", features = ["all-languages", "rand"] } derive_more = "1.0.0" accesskit = "=0.16.1" -egui = { version = "0.29.1" } +egui = "0.29.1" egui_extras = "0.29.1" +egui_commonmark = "0.18.0" rfd = "0.15.1" qrcode = "0.14.1" eframe = { version = "0.29.1", features = ["persistence"] } @@ -25,7 +26,7 @@ strum = { version = "0.26.1", features = ["derive"] } bs58 = "0.5.0" base64 = "0.22.1" copypasta = "0.10.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "6a7016af671fcf1d1449e418b0e5aa5c943bde8d" } +dash-sdk = { git = "https://github.com/dashpay/platform.git", rev = "952b1f26b8d3571aab608d4361263e56ded5f1f5" } thiserror = "1" serde = "1.0.197" serde_json = "1.0.120" @@ -42,13 +43,10 @@ envy = "0.4.2" tokio-util = "0.7.10" chrono = "0.4" chrono-humanize = "0.2.3" -grovedb-version = "2.0.3" +grovedb-version = "2.2.1" sha2 = "0.10.8" -arboard = { version = "3.4.0", default-features = false, features = [ - "windows-sys", -] } +arboard = { version = "3.4.0", default-features = false, features = ["windows-sys"] } directories = "5.0" - rusqlite = { version = "0.32.1", features = ["functions"]} serde_yaml = "0.9.34+deprecated" image = { version = "0.25.2", default-features = false, features = ["png"] } @@ -61,6 +59,7 @@ argon2 = "0.5" # For Argon2 key derivation aes-gcm = "0.10" # For AES-256-GCM encryption crossbeam-channel = "0.5.13" regex = "1.11" +ordered-float = "4.6.0" [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10" diff --git a/dash_core_configs/devnet.conf b/dash_core_configs/devnet.conf new file mode 100644 index 000000000..d19c92a9a --- /dev/null +++ b/dash_core_configs/devnet.conf @@ -0,0 +1,12 @@ +# Devnet name goes here +devnet= + +# Devnet name goes here too (ex: [devnetName]) +[] +rpcport=12998 +rpcallowip=127.0.0.1/32 +rpcuser=dashrpc +rpcpassword=password +server=1 +zmqpubrawtxlocksig=tcp://0.0.0.0:23710 +zmqpubrawchainlock=tcp://0.0.0.0:23710 diff --git a/icons/tokens.png b/icons/tokens.png new file mode 100644 index 000000000..4dee9c4b6 Binary files /dev/null and b/icons/tokens.png differ diff --git a/src/app.rs b/src/app.rs index a59672764..6e0c8e674 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use crate::ui::dpns::dpns_contested_names_screen::{ }; use crate::ui::identities::identities_screen::IdentitiesScreen; use crate::ui::network_chooser_screen::NetworkChooserScreen; +use crate::ui::tokens::tokens_screen::{TokensScreen, TokensSubscreen}; use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::tools::transition_visualizer_screen::TransitionVisualizerScreen; @@ -54,12 +55,15 @@ pub struct AppState { pub chosen_network: Network, pub mainnet_app_context: Arc, pub testnet_app_context: Option>, + pub devnet_app_context: Option>, + pub local_app_context: Option>, pub mainnet_core_zmq_listener: CoreZMQListener, pub testnet_core_zmq_listener: CoreZMQListener, + pub devnet_core_zmq_listener: CoreZMQListener, + pub local_core_zmq_listener: CoreZMQListener, pub core_message_receiver: mpsc::Receiver<(ZMQMessage, Network)>, pub task_result_sender: tokiompsc::Sender, // Channel sender for sending task results pub task_result_receiver: tokiompsc::Receiver, // Channel receiver for receiving task results - last_repaint: Instant, // Track the last time we requested a repaint last_scheduled_vote_check: Instant, // Last time we checked if there are scheduled masternode votes to cast } @@ -67,9 +71,6 @@ pub struct AppState { pub enum DesiredAppAction { None, Refresh, - PopScreen, - GoToMainScreen, - SwitchNetwork(Network), AddScreenType(ScreenType), BackendTask(BackendTask), BackendTasks(Vec, BackendTasksExecutionMode), @@ -82,8 +83,6 @@ impl DesiredAppAction { DesiredAppAction::None => AppAction::None, DesiredAppAction::Refresh => AppAction::Refresh, DesiredAppAction::Custom(message) => AppAction::Custom(message.clone()), - DesiredAppAction::PopScreen => AppAction::PopScreen, - DesiredAppAction::GoToMainScreen => AppAction::GoToMainScreen, DesiredAppAction::AddScreenType(screen_type) => { AppAction::AddScreen(screen_type.create_screen(app_context)) } @@ -93,7 +92,6 @@ impl DesiredAppAction { DesiredAppAction::BackendTasks(tasks, mode) => { AppAction::BackendTasks(tasks.clone(), mode.clone()) } - DesiredAppAction::SwitchNetwork(network) => AppAction::SwitchNetwork(*network), } } } @@ -159,7 +157,11 @@ impl AppState { std::process::exit(1); } }; - let testnet_app_context = AppContext::new(Network::Testnet, db.clone(), password_info); + let testnet_app_context = + AppContext::new(Network::Testnet, db.clone(), password_info.clone()); + let devnet_app_context = + AppContext::new(Network::Devnet, db.clone(), password_info.clone()); + let local_app_context = AppContext::new(Network::Regtest, db.clone(), password_info); let mut identities_screen = IdentitiesScreen::new(&mainnet_app_context); let mut dpns_active_contests_screen = @@ -175,6 +177,12 @@ impl AppState { let mut proof_visualizer_screen = ProofVisualizerScreen::new(&mainnet_app_context); let mut proof_log_screen = ProofLogScreen::new(&mainnet_app_context); let mut document_query_screen = DocumentQueryScreen::new(&mainnet_app_context); + let mut tokens_balances_screen = + TokensScreen::new(&mainnet_app_context, TokensSubscreen::MyTokens); + let mut token_search_screen = + TokensScreen::new(&mainnet_app_context, TokensSubscreen::SearchTokens); + let mut token_creator_screen = + TokensScreen::new(&mainnet_app_context, TokensSubscreen::TokenCreator); let (custom_dash_qt_path, overwrite_dash_conf) = match settings.clone() { Some((.., db_custom_dash_qt_path, db_overwrite_dash_qt)) => { @@ -189,6 +197,8 @@ impl AppState { let mut network_chooser_screen = NetworkChooserScreen::new( &mainnet_app_context, testnet_app_context.as_ref(), + devnet_app_context.as_ref(), + local_app_context.as_ref(), Network::Dash, custom_dash_qt_path, overwrite_dash_conf, @@ -200,9 +210,11 @@ impl AppState { let mut chosen_network = Network::Dash; - if let Some((network, screen_type, _, _, _)) = settings { + if let Some((network, screen_type, _password_info, _, _)) = settings { selected_main_screen = screen_type; chosen_network = network; + network_chooser_screen.current_network = chosen_network; + if chosen_network == Network::Testnet && testnet_app_context.is_some() { let testnet_app_context = testnet_app_context.as_ref().unwrap(); identities_screen = IdentitiesScreen::new(testnet_app_context); @@ -219,16 +231,62 @@ impl AppState { document_query_screen = DocumentQueryScreen::new(testnet_app_context); wallets_balances_screen = WalletsBalancesScreen::new(testnet_app_context); proof_log_screen = ProofLogScreen::new(testnet_app_context); + tokens_balances_screen = + TokensScreen::new(testnet_app_context, TokensSubscreen::MyTokens); + token_search_screen = + TokensScreen::new(testnet_app_context, TokensSubscreen::SearchTokens); + token_creator_screen = + TokensScreen::new(testnet_app_context, TokensSubscreen::TokenCreator); + } else if chosen_network == Network::Devnet && devnet_app_context.is_some() { + let devnet_app_context = devnet_app_context.as_ref().unwrap(); + identities_screen = IdentitiesScreen::new(devnet_app_context); + dpns_active_contests_screen = + DPNSScreen::new(&devnet_app_context, DPNSSubscreen::Active); + dpns_past_contests_screen = + DPNSScreen::new(&devnet_app_context, DPNSSubscreen::Past); + dpns_my_usernames_screen = + DPNSScreen::new(&devnet_app_context, DPNSSubscreen::Owned); + dpns_scheduled_votes_screen = + DPNSScreen::new(&devnet_app_context, DPNSSubscreen::ScheduledVotes); + transition_visualizer_screen = TransitionVisualizerScreen::new(devnet_app_context); + proof_visualizer_screen = ProofVisualizerScreen::new(devnet_app_context); + document_query_screen = DocumentQueryScreen::new(devnet_app_context); + wallets_balances_screen = WalletsBalancesScreen::new(devnet_app_context); + proof_log_screen = ProofLogScreen::new(devnet_app_context); + tokens_balances_screen = + TokensScreen::new(devnet_app_context, TokensSubscreen::MyTokens); + token_search_screen = + TokensScreen::new(devnet_app_context, TokensSubscreen::SearchTokens); + token_creator_screen = + TokensScreen::new(devnet_app_context, TokensSubscreen::TokenCreator); + } else if chosen_network == Network::Regtest && local_app_context.is_some() { + let local_app_context = local_app_context.as_ref().unwrap(); + identities_screen = IdentitiesScreen::new(local_app_context); + dpns_active_contests_screen = + DPNSScreen::new(&local_app_context, DPNSSubscreen::Active); + dpns_past_contests_screen = + DPNSScreen::new(&local_app_context, DPNSSubscreen::Past); + dpns_my_usernames_screen = + DPNSScreen::new(&local_app_context, DPNSSubscreen::Owned); + dpns_scheduled_votes_screen = + DPNSScreen::new(&local_app_context, DPNSSubscreen::ScheduledVotes); + transition_visualizer_screen = TransitionVisualizerScreen::new(local_app_context); + proof_visualizer_screen = ProofVisualizerScreen::new(local_app_context); + document_query_screen = DocumentQueryScreen::new(local_app_context); + wallets_balances_screen = WalletsBalancesScreen::new(local_app_context); + proof_log_screen = ProofLogScreen::new(local_app_context); + tokens_balances_screen = + TokensScreen::new(local_app_context, TokensSubscreen::MyTokens); + token_search_screen = + TokensScreen::new(local_app_context, TokensSubscreen::SearchTokens); + token_creator_screen = + TokensScreen::new(local_app_context, TokensSubscreen::TokenCreator); } - network_chooser_screen.current_network = chosen_network; } // // Create a channel with a buffer size of 32 (adjust as needed) let (task_result_sender, task_result_receiver) = tokiompsc::channel(256); - // Initialize the last repaint time to the current instant - let last_repaint = Instant::now(); - // Create a channel for communication with the InstantSendListener let (core_message_sender, core_message_receiver) = mpsc::channel(); @@ -240,7 +298,7 @@ impl AppState { ) .expect("Failed to create mainnet InstantSend listener"); - let tx_zmq_status_option = match testnet_app_context { + let testnet_tx_zmq_status_option = match testnet_app_context { Some(ref context) => Some(context.sx_zmq_status.clone()), None => None, }; @@ -248,11 +306,37 @@ impl AppState { let testnet_core_zmq_listener = CoreZMQListener::spawn_listener( Network::Testnet, "tcp://127.0.0.1:23709", - core_message_sender, // Use the original sender or create a new one if needed - tx_zmq_status_option, + core_message_sender.clone(), // Use the original sender or create a new one if needed + testnet_tx_zmq_status_option, ) .expect("Failed to create testnet InstantSend listener"); + let devnet_tx_zmq_status_option = match devnet_app_context { + Some(ref context) => Some(context.sx_zmq_status.clone()), + None => None, + }; + + let devnet_core_zmq_listener = CoreZMQListener::spawn_listener( + Network::Devnet, + "tcp://127.0.0.1:23710", + core_message_sender.clone(), + devnet_tx_zmq_status_option, + ) + .expect("Failed to create devnet InstantSend listener"); + + let local_tx_zmq_status_option = match local_app_context { + Some(ref context) => Some(context.sx_zmq_status.clone()), + None => None, + }; + + let local_core_zmq_listener = CoreZMQListener::spawn_listener( + Network::Regtest, + "tcp://127.0.0.1:20302", + core_message_sender, + local_tx_zmq_status_option, + ) + .expect("Failed to create local InstantSend listener"); + Self { main_screens: [ ( @@ -299,6 +383,18 @@ impl AppState { RootScreenType::RootScreenNetworkChooser, Screen::NetworkChooserScreen(network_chooser_screen), ), + ( + RootScreenType::RootScreenMyTokenBalances, + Screen::TokensScreen(tokens_balances_screen), + ), + ( + RootScreenType::RootScreenTokenSearch, + Screen::TokensScreen(token_search_screen), + ), + ( + RootScreenType::RootScreenTokenCreator, + Screen::TokensScreen(token_creator_screen), + ), ] .into(), selected_main_screen, @@ -306,12 +402,15 @@ impl AppState { chosen_network, mainnet_app_context, testnet_app_context, + devnet_app_context, + local_app_context, mainnet_core_zmq_listener, testnet_core_zmq_listener, + devnet_core_zmq_listener, + local_core_zmq_listener, core_message_receiver, task_result_sender, task_result_receiver, - last_repaint, last_scheduled_vote_check: Instant::now(), } } @@ -320,8 +419,8 @@ impl AppState { match self.chosen_network { Network::Dash => &self.mainnet_app_context, Network::Testnet => self.testnet_app_context.as_ref().expect("expected testnet"), - Network::Devnet => todo!(), - Network::Regtest => todo!(), + Network::Devnet => self.devnet_app_context.as_ref().expect("expected devnet"), + Network::Regtest => self.local_app_context.as_ref().expect("expected local"), _ => todo!(), } } @@ -369,12 +468,6 @@ impl AppState { }); } - pub fn active_root_screen(&self) -> &Screen { - self.main_screens - .get(&self.selected_main_screen) - .expect("expected to get screen") - } - pub fn active_root_screen_mut(&mut self) -> &mut Screen { self.main_screens .get_mut(&self.selected_main_screen) @@ -389,14 +482,6 @@ impl AppState { } } - pub fn visible_screen(&self) -> &Screen { - if let Some(last_screen) = self.screen_stack.last() { - last_screen - } else { - self.active_root_screen() - } - } - pub fn visible_screen_mut(&mut self) -> &mut Screen { if self.screen_stack.is_empty() { self.active_root_screen_mut() @@ -404,14 +489,6 @@ impl AppState { self.screen_stack.last_mut().unwrap() } } - - pub fn visible_screen_type(&self) -> ScreenType { - if let Some(last_screen) = self.screen_stack.last() { - last_screen.screen_type() - } else { - self.selected_main_screen.into() - } - } } impl AppState { @@ -462,9 +539,7 @@ impl App for AppState { // Handle the result on the main thread match task_result { TaskResult::Success(message) => match message { - BackendTaskSuccessResult::None => { - self.visible_screen_mut().pop_on_success(); - } + BackendTaskSuccessResult::None => {} BackendTaskSuccessResult::Refresh => { self.visible_screen_mut().refresh(); } @@ -472,18 +547,6 @@ impl App for AppState { self.visible_screen_mut() .display_message(&message, MessageType::Success); } - BackendTaskSuccessResult::Documents(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::CoreItem(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::SuccessfulVotes(_) => { - self.visible_screen_mut().refresh(); - } - BackendTaskSuccessResult::DPNSVoteResults(_) => { - self.visible_screen_mut().display_task_result(message); - } BackendTaskSuccessResult::CastScheduledVote(vote) => { let _ = self .current_app_context() @@ -494,19 +557,7 @@ impl App for AppState { ); self.visible_screen_mut().refresh(); } - BackendTaskSuccessResult::RegisteredIdentity(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::ToppedUpIdentity(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::FetchedContract(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::FetchedContracts(_) => { - self.visible_screen_mut().display_task_result(message); - } - BackendTaskSuccessResult::PageDocuments(_, _) => { + _ => { self.visible_screen_mut().display_task_result(message); } }, @@ -528,9 +579,24 @@ impl App for AppState { if let Some(context) = self.testnet_app_context.as_ref() { context } else { - // Handle the case when testnet_app_context is None eprintln!("No testnet app context available for Testnet"); - continue; // Skip this iteration or handle as needed + continue; + } + } + Network::Devnet => { + if let Some(context) = self.devnet_app_context.as_ref() { + context + } else { + eprintln!("No devnet app context available"); + continue; + } + } + Network::Regtest => { + if let Some(context) = self.local_app_context.as_ref() { + context + } else { + eprintln!("No local app context available"); + continue; } } _ => continue, diff --git a/src/app_dir.rs b/src/app_dir.rs index 1ddf24937..b01b09356 100644 --- a/src/app_dir.rs +++ b/src/app_dir.rs @@ -1,13 +1,16 @@ -use directories::ProjectDirs; +use directories::{ProjectDirs, UserDirs}; use std::fs; use std::path::PathBuf; +use dash_sdk::dpp::dashcore::Network; const QUALIFIER: &str = ""; // Typically empty on macOS and Linux const ORGANIZATION: &str = ""; const APPLICATION: &str = "Dash-Evo-Tool"; -pub fn app_user_data_dir_path() -> Result { - let proj_dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION).ok_or_else(|| { +const CORE_APPLICATION: &str = "DashCore"; + +fn user_data_dir_path(app: &str) -> Result { + let proj_dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, app).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, "Failed to determine project directories", @@ -15,6 +18,44 @@ pub fn app_user_data_dir_path() -> Result { })?; Ok(proj_dirs.config_dir().to_path_buf()) } + +pub fn app_user_data_dir_path() -> Result { + user_data_dir_path(APPLICATION) +} + +pub fn core_user_data_dir_path() -> Result { + #[cfg(target_os = "linux")] + { + UserDirs::new() + .and_then(|dirs| dirs.home_dir().to_owned().into()) + .map(|home_dir| home_dir.join(".dashcore")) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Failed to determine user home directory", + ) + }) + } + + #[cfg(not(target_os = "linux"))] + { + user_data_dir_path(CORE_APPLICATION) + } +} + +pub fn core_cookie_path(network: Network, devnet_name: &Option) -> Result { + core_user_data_dir_path().map(|path| { + let network_dir = match network { + Network::Dash => "", + Network::Testnet => "testnet3", + Network::Devnet => devnet_name.as_deref().unwrap_or(""), + Network::Regtest => "regtest", + _ => unimplemented!(), + }; + path.join(network_dir).join(".cookie") + }) +} + pub fn create_app_user_data_directory_if_not_exists() -> Result<(), std::io::Error> { let app_data_dir = app_user_data_dir_path()?; fs::create_dir_all(&app_data_dir)?; diff --git a/src/backend_task/broadcast_state_transition.rs b/src/backend_task/broadcast_state_transition.rs index 4309c33df..772c65156 100644 --- a/src/backend_task/broadcast_state_transition.rs +++ b/src/backend_task/broadcast_state_transition.rs @@ -13,7 +13,7 @@ impl AppContext { state_transition: StateTransition, sdk: &Sdk, ) -> Result { - match state_transition.broadcast_and_wait(sdk, None).await { + match state_transition.broadcast(sdk, None).await { Ok(_) => Ok(BackendTaskSuccessResult::Message( "State transition broadcasted successfully".to_string(), )), diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index b7834c927..9d23c6243 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -67,12 +67,23 @@ impl AppContext { // Run all futures concurrently let results = join_all(futures).await; - // Map them into (name, choice, Ok(())) or (name, choice, Err(String)) tuples let final_results = results .into_iter() - .map(|(name, choice, result)| match result { - Ok(_) => (name.clone(), choice.clone(), Ok(())), - Err(err_msg) => (name.clone(), choice.clone(), Err(err_msg)), + .flat_map(|(name, vote_choice, det_execution_result)| { + match det_execution_result { + Ok(BackendTaskSuccessResult::DPNSVoteResults(platform_results)) => { + // Voting succeeded in DET, return the Platform results + platform_results + } + Err(det_err_msg) => { + // Voting failed in DET, return the error message + vec![(name.clone(), vote_choice.clone(), Err(det_err_msg))] + } + Ok(_) => { + // Got some other BackendTaskSuccessResult, this shouldn't occur + vec![(name.clone(), vote_choice.clone(), Ok(()))] + } + } }) .collect::>(); diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index 6f5d9868a..49266ab6f 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -50,15 +50,12 @@ impl AppContext { Ok(contested_resources) => contested_resources, Err(e) => { tracing::error!("Error fetching contested resources: {}", e); - if let dash_sdk::Error::Proof( - dash_sdk::ProofVerifierError::GroveDBProofVerificationError { - proof_bytes, - path_query, - height, - time_ms, - error, - }, - ) = &e + if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes, + height, + time_ms, + error, + }) = &e { // Encode the query using bincode let encoded_query = @@ -71,25 +68,25 @@ impl AppContext { Err(e) => return Err(e), }; - // Encode the path_query using bincode - let verification_path_query_bytes = - match bincode::encode_to_vec(&path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - format!("Error encoding path_query: {}", encode_err) - }) { - Ok(encoded_path_query) => encoded_path_query, - Err(e) => { - return Err(format!("Contested resource query failed: {}", e)) - } - }; + // // Encode the path_query using bincode + // let verification_path_query_bytes = + // match bincode::encode_to_vec(&path_query, bincode::config::standard()) + // .map_err(|encode_err| { + // tracing::error!("Error encoding path_query: {}", encode_err); + // format!("Error encoding path_query: {}", encode_err) + // }) { + // Ok(encoded_path_query) => encoded_path_query, + // Err(e) => { + // return Err(format!("Contested resource query failed: {}", e)) + // } + // }; if let Err(e) = self .db .insert_proof_log_item(ProofLogItem { request_type: RequestType::GetContestedResources, request_bytes: encoded_query, - verification_path_query_bytes, + verification_path_query_bytes: vec![], height: *height, time_ms: *time_ms, proof_bytes: proof_bytes.clone(), diff --git a/src/backend_task/contested_names/query_dpns_vote_contenders.rs b/src/backend_task/contested_names/query_dpns_vote_contenders.rs index 082793ba4..55aa79ecb 100644 --- a/src/backend_task/contested_names/query_dpns_vote_contenders.rs +++ b/src/backend_task/contested_names/query_dpns_vote_contenders.rs @@ -61,15 +61,12 @@ impl AppContext { } Err(e) => { tracing::error!("Error fetching vote contenders: {}", e); - if let dash_sdk::Error::Proof( - dash_sdk::ProofVerifierError::GroveDBProofVerificationError { - proof_bytes, - path_query, - height, - time_ms, - error, - }, - ) = &e + if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes, + height, + time_ms, + error, + }) = &e { // Encode the query using bincode let encoded_query = match bincode::encode_to_vec( @@ -84,23 +81,23 @@ impl AppContext { Err(e) => return Err(e), }; - // Encode the path_query using bincode - let verification_path_query_bytes = - match bincode::encode_to_vec(&path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - format!("Error encoding path_query: {}", encode_err) - }) { - Ok(encoded_path_query) => encoded_path_query, - Err(e) => return Err(e), - }; + // // Encode the path_query using bincode + // let verification_path_query_bytes = + // match bincode::encode_to_vec(&path_query, bincode::config::standard()) + // .map_err(|encode_err| { + // tracing::error!("Error encoding path_query: {}", encode_err); + // format!("Error encoding path_query: {}", encode_err) + // }) { + // Ok(encoded_path_query) => encoded_path_query, + // Err(e) => return Err(e), + // }; if let Err(e) = self .db .insert_proof_log_item(ProofLogItem { request_type: RequestType::GetContestedResourceIdentityVotes, request_bytes: encoded_query, - verification_path_query_bytes, + verification_path_query_bytes: vec![], height: *height, time_ms: *time_ms, proof_bytes: proof_bytes.clone(), diff --git a/src/backend_task/contested_names/query_ending_times.rs b/src/backend_task/contested_names/query_ending_times.rs index 826380932..714225694 100644 --- a/src/backend_task/contested_names/query_ending_times.rs +++ b/src/backend_task/contested_names/query_ending_times.rs @@ -62,15 +62,12 @@ impl AppContext { } Err(e) => { tracing::error!("Error fetching vote polls: {}", e); - if let dash_sdk::Error::Proof( - dash_sdk::ProofVerifierError::GroveDBProofVerificationError { - proof_bytes, - path_query, - height, - time_ms, - error, - }, - ) = &e + if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes, + height, + time_ms, + error, + }) = &e { // Encode the query using bincode let encoded_query = match bincode::encode_to_vec( @@ -85,23 +82,23 @@ impl AppContext { Err(e) => return Err(e), }; - // Encode the path_query using bincode - let verification_path_query_bytes = - match bincode::encode_to_vec(&path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - format!("Error encoding path_query: {}", encode_err) - }) { - Ok(encoded_path_query) => encoded_path_query, - Err(e) => return Err(e), - }; + // // Encode the path_query using bincode + // let verification_path_query_bytes = + // match bincode::encode_to_vec(&path_query, bincode::config::standard()) + // .map_err(|encode_err| { + // tracing::error!("Error encoding path_query: {}", encode_err); + // format!("Error encoding path_query: {}", encode_err) + // }) { + // Ok(encoded_path_query) => encoded_path_query, + // Err(e) => return Err(e), + // }; if let Err(e) = self .db .insert_proof_log_item(ProofLogItem { request_type: RequestType::GetVotePollsByEndDate, request_bytes: encoded_query, - verification_path_query_bytes, + verification_path_query_bytes: vec![], height: *height, time_ms: *time_ms, proof_bytes: proof_bytes.clone(), diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 29d35eac7..1630b9011 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -54,7 +54,6 @@ impl AppContext { }; let mut vote_results = vec![]; - let mut strength = 0; // Iterate over the provided voters (QualifiedIdentity) for qualified_identity in voters.iter() { @@ -79,7 +78,6 @@ impl AppContext { .map(|_| ()) .map_err(|e| format!("Error voting: {}", e)); - strength += qualified_identity.identity_type.vote_strength(); vote_results.push((name.clone(), vote_choice, result)); } else { return Err(format!( @@ -89,16 +87,6 @@ impl AppContext { } } - // To do: if the voter already voted previously, the previous vote count should be removed - self.db - .update_vote_count( - name, - self.network.to_string().as_str(), - strength, - vote_choice, - ) - .map_err(|e| format!("Error voting: Error updating vote count: {}", e))?; - Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results)) } } diff --git a/src/backend_task/contract.rs b/src/backend_task/contract.rs index 5be66fc5a..da5dd207a 100644 --- a/src/backend_task/contract.rs +++ b/src/backend_task/contract.rs @@ -1,6 +1,7 @@ use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::system_data_contracts::dpns_contract; -use dash_sdk::platform::{DataContract, Fetch, FetchMany, Identifier}; +use dash_sdk::platform::{DataContract, Fetch, FetchMany, Identifier, IdentityPublicKey}; use dash_sdk::Sdk; use super::BackendTaskSuccessResult; @@ -11,6 +12,7 @@ pub(crate) enum ContractTask { FetchContract(Identifier, Option), FetchContracts(Vec), RemoveContract(Identifier), + RegisterDataContract(DataContract, String, QualifiedIdentity, IdentityPublicKey), } impl AppContext { @@ -73,6 +75,23 @@ impl AppContext { Err(e) => Err(format!("Error fetching DPNS contract: {}", e.to_string())), } } + ContractTask::RegisterDataContract(data_contract, alias, identity, signing_key) => { + AppContext::register_data_contract( + &self, + data_contract, + alias, + identity, + signing_key, + sdk, + ) + .await + .map(|_| { + BackendTaskSuccessResult::Message( + "Successfully registered contract".to_string(), + ) + }) + .map_err(|e| format!("Error registering contract: {}", e.to_string())) + } ContractTask::RemoveContract(identifier) => self .remove_contract(&identifier) .map(|_| { diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 795729143..d4539ea81 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -1,8 +1,9 @@ mod refresh_wallet_info; mod start_dash_qt; +use crate::app_dir::core_cookie_path; use crate::backend_task::BackendTaskSuccessResult; -use crate::config::Config; +use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; use dash_sdk::dashcore_rpc::RpcApi; @@ -33,7 +34,12 @@ impl PartialEq for CoreTask { pub(crate) enum CoreItem { ReceivedAvailableUTXOTransaction(Transaction, Vec<(OutPoint, TxOut, Address)>), ChainLock(ChainLock, Network), - ChainLocks(Option, Option), // Mainnet, Testnet + ChainLocks( + Option, + Option, + Option, + Option, + ), // Mainnet, Testnet, Devnet, Local } impl AppContext { @@ -41,6 +47,8 @@ impl AppContext { match task { CoreTask::GetBestChainLock => self .core_client + .read() + .expect("Core client lock was poisoned") .get_best_chain_lock() .map(|chain_lock| { BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock( @@ -50,85 +58,45 @@ impl AppContext { }) .map_err(|e| e.to_string()), CoreTask::GetBestChainLocks => { - tracing::info!("Getting best chain locks for testnet and mainnet"); - // Load configs - let config = match Config::load() { - Ok(config) => config, - Err(e) => { - return Err(format!("Failed to load config: {}", e)); - } - }; + let config = Config::load().map_err(|e| format!("Failed to load config: {}", e))?; + let maybe_mainnet_config = config.config_for_network(Network::Dash); let maybe_testnet_config = config.config_for_network(Network::Testnet); + let maybe_devnet_config = config.config_for_network(Network::Devnet); + let maybe_local_config = config.config_for_network(Network::Regtest); - // Get mainnet best chainlock - let mainnet_result = if let Some(mainnet_config) = maybe_mainnet_config { - let mainnet_addr = format!( - "http://{}:{}", - mainnet_config.core_host, mainnet_config.core_rpc_port - ); - let mainnet_client = Client::new( - &mainnet_addr, - Auth::UserPass( - mainnet_config.core_rpc_user.to_string(), - mainnet_config.core_rpc_password.to_string(), - ), - ) - .map_err(|_| "Failed to create mainnet client".to_string())?; - mainnet_client.get_best_chain_lock().map_err(|e| { - format!( - "Failed to get best chain lock for mainnet: {}", - e.to_string() - ) - }) - } else { - Err("Mainnet config not found".to_string()) - }; + let mainnet_result = Self::get_best_chain_lock(maybe_mainnet_config, Network::Dash); + let testnet_result = + Self::get_best_chain_lock(maybe_testnet_config, Network::Testnet); + let devnet_result = Self::get_best_chain_lock(maybe_devnet_config, Network::Devnet); + let local_result = Self::get_best_chain_lock(maybe_local_config, Network::Regtest); - // Get testnet best chainlock - let testnet_result = if let Some(testnet_config) = maybe_testnet_config { - let testnet_addr = format!( - "http://{}:{}", - testnet_config.core_host, testnet_config.core_rpc_port - ); - let testnet_client = Client::new( - &testnet_addr, - Auth::UserPass( - testnet_config.core_rpc_user.to_string(), - testnet_config.core_rpc_password.to_string(), - ), - ) - .map_err(|_| "Failed to create testnet client".to_string())?; - testnet_client.get_best_chain_lock().map_err(|e| { - format!( - "Failed to get best chain lock for testnet: {}", - e.to_string() - ) - }) - } else { - Err("Testnet config not found".to_string()) - }; + // Convert each to Option + let mainnet_chainlock = mainnet_result.ok(); + let testnet_chainlock = testnet_result.ok(); + let devnet_chainlock = devnet_result.ok(); + let local_chainlock = local_result.ok(); - // Handle results - match (mainnet_result, testnet_result) { - (Ok(mainnet_chainlock), Ok(testnet_chainlock)) => { - Ok(BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( - Some(mainnet_chainlock), - Some(testnet_chainlock), - ))) - } - (Ok(mainnet_chainlock), Err(_)) => Ok(BackendTaskSuccessResult::CoreItem( - CoreItem::ChainLocks(Some(mainnet_chainlock), None), - )), - (Err(_), Ok(testnet_chainlock)) => Ok(BackendTaskSuccessResult::CoreItem( - CoreItem::ChainLocks(None, Some(testnet_chainlock)), - )), - (Err(_), Err(_)) => { - Err("Failed to get best chain lock for both mainnet and testnet" - .to_string()) - } + // If all three failed, bail out with an error + if mainnet_chainlock.is_none() + && testnet_chainlock.is_none() + && devnet_chainlock.is_none() + && local_chainlock.is_none() + { + return Err( + "Failed to get best chain lock for mainnet, testnet, devnet, and local network" + .to_string(), + ); } + + // Otherwise, return the successes we have + Ok(BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( + mainnet_chainlock, + testnet_chainlock, + devnet_chainlock, + local_chainlock, + ))) } CoreTask::RefreshWalletInfo(wallet) => self .refresh_wallet_info(wallet) @@ -139,4 +107,48 @@ impl AppContext { .map(|_| BackendTaskSuccessResult::None), } } + + fn get_best_chain_lock( + config: &Option, + network: Network, + ) -> Result { + if let Some(network_config) = config { + let addr = format!( + "http://{}:{}", + network_config.core_host, network_config.core_rpc_port + ); + + let cookie_path = core_cookie_path(network, &network_config.devnet_name) + .map_err(|e| format!("Failed to get core cookie path: {}", e))?; + + // Try cookie authentication first + let client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { + Ok(client) => Ok(client), + Err(_) => { + tracing::info!( + "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + cookie_path + ); + Client::new( + &addr, + Auth::UserPass( + network_config.core_rpc_user.to_string(), + network_config.core_rpc_password.to_string(), + ), + ) + } + } + .map_err(|_| format!("Failed to create {} client", network.to_string()))?; + + client.get_best_chain_lock().map_err(|e| { + format!( + "Failed to get best chain lock for {}: {}", + network.to_string(), + e.to_string() + ) + }) + } else { + Err(format!("{} config not found", network.to_string())) + } + } } diff --git a/src/backend_task/core/refresh_wallet_info.rs b/src/backend_task/core/refresh_wallet_info.rs index 68ae2f9a4..73921bd77 100644 --- a/src/backend_task/core/refresh_wallet_info.rs +++ b/src/backend_task/core/refresh_wallet_info.rs @@ -30,7 +30,12 @@ impl AppContext { // Step 2: Iterate over each address and update balances for address in &addresses { // Fetch balance for the address from Dash Core - match self.core_client.get_received_by_address(address, None) { + match self + .core_client + .read() + .expect("Core client lock was poisoned") + .get_received_by_address(address, None) + { Ok(new_balance) => { // Update the wallet's address_balances and database { @@ -47,7 +52,14 @@ impl AppContext { // Step 3: Reload UTXOs using the wallet's existing method let utxo_map = { let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - match wallet_guard.reload_utxos(&self.core_client, self.network, Some(self)) { + match wallet_guard.reload_utxos( + &self + .core_client + .read() + .expect("Core client lock was poisoned"), + self.network, + Some(self), + ) { Ok(utxo_map) => utxo_map, Err(e) => { eprintln!("Error reloading UTXOs: {}", e); diff --git a/src/backend_task/core/start_dash_qt.rs b/src/backend_task/core/start_dash_qt.rs index 2cf92c880..db998cafc 100644 --- a/src/backend_task/core/start_dash_qt.rs +++ b/src/backend_task/core/start_dash_qt.rs @@ -40,6 +40,8 @@ impl AppContext { let config_file: &str = match network { Network::Dash => "dash_core_configs/mainnet.conf", Network::Testnet => "dash_core_configs/testnet.conf", + Network::Devnet => "dash_core_configs/devnet.conf", + Network::Regtest => "dash_core_configs/local.conf", _ => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -58,6 +60,10 @@ impl AppContext { command.arg(format!("-conf={}", config_path.display())); } else if network == Network::Testnet { command.arg("-testnet"); + } else if network == Network::Devnet { + command.arg("-devnet"); + } else if network == Network::Regtest { + command.arg("-local"); } // Spawn the Dash-Qt process diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c9927c6ce..e9c153dc1 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -247,7 +247,12 @@ impl AppContext { start: None, }; - let maybe_owned_dpns_names = Document::fetch_many(&self.sdk, dpns_names_document_query) + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 7c5c4d971..8a6e5cd95 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -59,7 +59,12 @@ impl AppContext { start: None, }; - let maybe_owned_dpns_names = Document::fetch_many(&self.sdk, dpns_names_document_query) + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 13fc093f5..e803e04bc 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -35,7 +35,12 @@ impl AppContext { start: None, }; - let owned_dpns_names = Document::fetch_many(&self.sdk, dpns_names_document_query) + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 4389e5087..823b986e7 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -131,8 +131,8 @@ impl AppContext { preorder_document_type.to_owned_document_type(), entropy.0, public_key.clone(), - self.dpns_contract.clone(), &qualified_identity, + None, ) .await .map_err(|e| e.to_string())?; @@ -143,8 +143,8 @@ impl AppContext { domain_document_type.to_owned_document_type(), entropy.0, public_key.clone(), - self.dpns_contract.clone(), &qualified_identity, + None, ) .await .map_err(|e| e.to_string())?; @@ -164,7 +164,12 @@ impl AppContext { start: None, }; - let owned_dpns_names = Document::fetch_many(&self.sdk, dpns_names_document_query) + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index c44cc1c62..510af6782 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -118,7 +118,10 @@ impl AppContext { identity_funding_method, } = input; - let sdk = self.sdk.clone(); + let sdk = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await @@ -143,6 +146,8 @@ impl AppContext { // we need to make sure the instant send asset lock is recent let raw_transaction_info = self .core_client + .read() + .expect("Core client lock was poisoned") .get_raw_transaction_info(&tx_id, None) .map_err(|e| e.to_string())?; @@ -179,7 +184,14 @@ impl AppContext { Ok(transaction) => transaction, Err(_) => { wallet - .reload_utxos(&self.core_client, self.network, Some(self)) + .reload_utxos( + &self + .core_client + .read() + .expect("Core client lock was poisoned"), + self.network, + Some(self), + ) .map_err(|e| e.to_string())?; wallet.registration_asset_lock_transaction( sdk.network, @@ -206,6 +218,8 @@ impl AppContext { } self.core_client + .read() + .expect("Core client lock was poisoned") .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; @@ -271,6 +285,8 @@ impl AppContext { } self.core_client + .read() + .expect("Core client lock was poisoned") .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; @@ -358,6 +374,7 @@ impl AppContext { asset_lock_proof.clone(), &asset_lock_proof_private_key, &qualified_identity, + None, ) .await { @@ -370,6 +387,7 @@ impl AppContext { asset_lock_proof.clone(), &asset_lock_proof_private_key, &qualified_identity, + None, ) .await .map_err(|e| { diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 8b483b4f9..1b3ff4614 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -31,7 +31,10 @@ impl AppContext { identity_funding_method, } = input; - let sdk = self.sdk.clone(); + let sdk = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await @@ -58,6 +61,8 @@ impl AppContext { // we need to make sure the instant send asset lock is recent let raw_transaction_info = self .core_client + .read() + .expect("Core client lock was poisoned") .get_raw_transaction_info(&tx_id, None) .map_err(|e| e.to_string())?; @@ -98,7 +103,14 @@ impl AppContext { Ok(transaction) => transaction, Err(_) => { wallet - .reload_utxos(&self.core_client, self.network, Some(self)) + .reload_utxos( + &self + .core_client + .read() + .expect("Core client lock was poisoned"), + self.network, + Some(self), + ) .map_err(|e| e.to_string())?; wallet.top_up_asset_lock_transaction( sdk.network, @@ -126,6 +138,8 @@ impl AppContext { } self.core_client + .read() + .expect("Core client lock was poisoned") .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; @@ -197,6 +211,8 @@ impl AppContext { } self.core_client + .read() + .expect("Core client lock was poisoned") .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; @@ -247,6 +263,7 @@ impl AppContext { asset_lock_proof.clone(), &asset_lock_proof_private_key, None, + None, ) .await { @@ -260,6 +277,7 @@ impl AppContext { asset_lock_proof.clone(), &asset_lock_proof_private_key, None, + None, ) .await .map_err(|e| { diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index b32237983..a3044b195 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -16,11 +16,16 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let remaining_balance = qualified_identity + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let (sender_balance, receiver_balance) = qualified_identity .identity .clone() .transfer_credits( - &self.sdk, + &sdk_guard, to_identifier, credits, id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)), @@ -29,7 +34,7 @@ impl AppContext { ) .await .map_err(|e| format!("Transfer error: {}", e))?; - qualified_identity.identity.set_balance(remaining_balance); + qualified_identity.identity.set_balance(sender_balance); // If the receiver is a local qualified identity, update its balance too if let Some(receiver) = self @@ -38,9 +43,7 @@ impl AppContext { .iter_mut() .find(|qi| qi.identity.id() == to_identifier) { - receiver - .identity - .set_balance(receiver.identity.balance() + credits); + receiver.identity.set_balance(receiver_balance); self.update_local_qualified_identity(receiver) .map_err(|e| format!("Transfer error: {}", e))?; } diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index cf10283f7..0f6a314b9 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -16,11 +16,16 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { + let sdk_guard = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + let remaining_balance = qualified_identity .identity .clone() .withdraw( - &self.sdk, + &sdk_guard, to_address, credits, Some(1), diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 16c5f5347..53150228d 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -6,6 +6,7 @@ use crate::backend_task::document::DocumentTask; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::tokens::tokens_screen::IdentityTokenBalance; use contested_names::ScheduledDPNSVote; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::state_transition::StateTransition; @@ -16,6 +17,7 @@ use dash_sdk::platform::{Document, Identifier}; use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; use std::sync::Arc; +use tokens::TokenTask; use tokio::sync::mpsc; pub mod broadcast_state_transition; @@ -24,6 +26,8 @@ pub mod contract; pub mod core; pub mod document; pub mod identity; +pub mod register_contract; +pub mod tokens; #[derive(Debug, Clone, PartialEq)] pub(crate) enum BackendTask { @@ -33,6 +37,7 @@ pub(crate) enum BackendTask { ContestedResourceTask(ContestedResourceTask), CoreTask(CoreTask), BroadcastStateTransition(StateTransition), + TokenTask(TokenTask), } #[derive(Debug, Clone, PartialEq)] @@ -50,6 +55,8 @@ pub(crate) enum BackendTaskSuccessResult { FetchedContract(DataContract), FetchedContracts(Vec>), PageDocuments(IndexMap>, Option), + TokensByKeyword(Vec), + TokensByKeywordPage(Vec, Option), } impl BackendTaskSuccessResult {} @@ -95,7 +102,10 @@ impl AppContext { task: BackendTask, sender: mpsc::Sender, ) -> Result { - let sdk = self.sdk.clone(); + let sdk = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; match task { BackendTask::ContractTask(contract_task) => { self.run_contract_task(contract_task, &sdk).await @@ -115,6 +125,9 @@ impl AppContext { self.broadcast_state_transition(state_transition, &sdk) .await } + BackendTask::TokenTask(token_task) => { + self.run_token_task(token_task, &sdk, sender).await + } } } } diff --git a/src/backend_task/register_contract.rs b/src/backend_task/register_contract.rs new file mode 100644 index 000000000..105370043 --- /dev/null +++ b/src/backend_task/register_contract.rs @@ -0,0 +1,51 @@ +use dash_sdk::{ + dpp::{ + data_contract::accessors::v0::DataContractV0Getters, + identity::{accessors::IdentityGettersV0, KeyType, Purpose, SecurityLevel}, + }, + platform::{transition::put_contract::PutContract, DataContract, IdentityPublicKey}, + Sdk, +}; + +use crate::{context::AppContext, model::qualified_identity::QualifiedIdentity}; + +use super::BackendTaskSuccessResult; + +impl AppContext { + pub async fn register_data_contract( + &self, + data_contract: DataContract, + alias: String, + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + sdk: &Sdk, + ) -> Result { + match data_contract + .put_to_platform_and_wait_for_response(&sdk, signing_key.clone(), &identity, None) + .await + { + Ok(returned_contract) => { + let optional_alias = match alias.is_empty() { + true => None, + false => Some(alias), + }; + self.db + .insert_contract_if_not_exists( + &returned_contract, + optional_alias.as_deref(), + self, + ) + .map_err(|e| { + format!( + "Error inserting contract into the database: {}", + e.to_string() + ) + })?; + Ok(BackendTaskSuccessResult::Message( + "DataContract successfully registered".to_string(), + )) + } + Err(e) => Err(format!("Failed to register DataContract: {:?}", e)), + } + } +} diff --git a/src/backend_task/tokens/burn_tokens.rs b/src/backend_task/tokens/burn_tokens.rs new file mode 100644 index 000000000..41a82d08f --- /dev/null +++ b/src/backend_task/tokens/burn_tokens.rs @@ -0,0 +1,48 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::burn::TokenBurnTransitionBuilder; +use dash_sdk::platform::{DataContract, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn burn_tokens( + &self, + owner_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + amount: u64, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + let builder = TokenBurnTransitionBuilder::new( + data_contract, + token_position, + owner_identity.identity.id(), + amount, + ); + + // Optionally chain `with_public_note(...)`, `with_settings(...)`, etc. + + let state_transition = builder + .sign(sdk, &signing_key, owner_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Burn Tokens transition: {:?}", e))?; + + // Broadcast and wait + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Burn Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message("BurnTokens".to_string())) + } +} diff --git a/src/backend_task/tokens/destroy_frozen_funds.rs b/src/backend_task/tokens/destroy_frozen_funds.rs new file mode 100644 index 000000000..9f304dc6e --- /dev/null +++ b/src/backend_task/tokens/destroy_frozen_funds.rs @@ -0,0 +1,50 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::destroy::TokenDestroyFrozenFundsTransitionBuilder; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn destroy_frozen_funds( + &self, + actor_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + frozen_identity: Identifier, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + let builder = TokenDestroyFrozenFundsTransitionBuilder::new( + data_contract, + token_position, + actor_identity.identity.id(), + frozen_identity, + ); + + // Optionally chain with_public_note(...), with_settings(...), etc. + + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing DestroyFrozenFunds transition: {:?}", e))?; + + // Broadcast + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting DestroyFrozenFunds transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message( + "DestroyFrozenFunds".to_string(), + )) + } +} diff --git a/src/backend_task/tokens/freeze_tokens.rs b/src/backend_task/tokens/freeze_tokens.rs new file mode 100644 index 000000000..41db68e1a --- /dev/null +++ b/src/backend_task/tokens/freeze_tokens.rs @@ -0,0 +1,48 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::freeze::TokenFreezeTransitionBuilder; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn freeze_tokens( + &self, + actor_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + freeze_identity: Identifier, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + let builder = TokenFreezeTransitionBuilder::new( + data_contract, + token_position, + actor_identity.identity.id(), + freeze_identity, + ); + + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Freeze Tokens transition: {:?}", e))?; + + // Broadcast + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Freeze Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message( + "FreezeTokens".to_string(), + )) + } +} diff --git a/src/backend_task/tokens/mint_tokens.rs b/src/backend_task/tokens/mint_tokens.rs new file mode 100644 index 000000000..2da3ae431 --- /dev/null +++ b/src/backend_task/tokens/mint_tokens.rs @@ -0,0 +1,54 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::mint::TokenMintTransitionBuilder; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; +use dash_sdk::Sdk; + +use tokio::sync::mpsc; + +impl AppContext { + pub async fn mint_tokens( + &self, + sending_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + amount: u64, + optional_recipient: Option, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + let builder = TokenMintTransitionBuilder::new( + data_contract, + token_position, + sending_identity.identity.id(), + amount, + ); + + let builder = if let Some(recipient_id) = optional_recipient { + builder.issued_to_identity_id(recipient_id) + } else { + builder + }; + + let state_transition = builder + .sign(sdk, &signing_key, sending_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Mint Tokens state transition: {:?}", e))?; + + // broadcast and wait + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Mint Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message("MintTokens".to_string())) + } +} diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs new file mode 100644 index 000000000..47fd85174 --- /dev/null +++ b/src/backend_task/tokens/mod.rs @@ -0,0 +1,454 @@ +use super::BackendTaskSuccessResult; +use crate::{app::TaskResult, context::AppContext, model::qualified_identity::QualifiedIdentity}; +use dash_sdk::{ + dpp::{ + data_contract::{ + associated_token::{ + token_configuration::v0::TokenConfigurationV0, + token_configuration_convention::{ + v0::TokenConfigurationLocalizationsV0, TokenConfigurationConvention, + }, + token_distribution_rules::TokenDistributionRules, + }, + change_control_rules::{ + authorized_action_takers::AuthorizedActionTakers, ChangeControlRules, + }, + config::DataContractConfig, + group::Group, + v1::DataContractV1, + TokenConfiguration, TokenContractPosition, + }, + identity::accessors::IdentityGettersV0, + ProtocolError, + }, + platform::{DataContract, Identifier, IdentityPublicKey}, + Sdk, +}; +use std::{collections::BTreeMap, sync::Arc}; +use tokio::sync::mpsc; + +mod burn_tokens; +mod destroy_frozen_funds; +mod freeze_tokens; +mod mint_tokens; +mod pause_tokens; +mod query_my_token_balances; +mod query_tokens; +mod resume_tokens; +mod transfer_tokens; +mod unfreeze_tokens; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum TokenTask { + RegisterTokenContract { + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + token_name: String, + should_capitalize: bool, + decimals: u16, + base_supply: u64, + max_supply: Option, + start_paused: bool, + keeps_history: bool, + main_control_group: Option, + + // Manual Mint + manual_minting_rules: ChangeControlRules, + manual_burning_rules: ChangeControlRules, + freeze_rules: ChangeControlRules, + unfreeze_rules: ChangeControlRules, + destroy_frozen_funds_rules: ChangeControlRules, + emergency_action_rules: ChangeControlRules, + max_supply_change_rules: ChangeControlRules, + conventions_change_rules: ChangeControlRules, + + // Main Control Group Change + main_control_group_change_authorized: AuthorizedActionTakers, + + distribution_rules: TokenDistributionRules, + groups: BTreeMap, + }, + QueryMyTokenBalances, + QueryTokensByKeyword(String), + QueryTokensByKeywordPage(String, Option), + MintTokens { + sending_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + amount: u64, + recipient_id: Option, + }, + TransferTokens { + sending_identity: QualifiedIdentity, + recipient_id: Identifier, + amount: u64, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + }, + BurnTokens { + owner_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + amount: u64, + }, + DestroyFrozenFunds { + actor_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + frozen_identity: Identifier, + }, + FreezeTokens { + actor_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + freeze_identity: Identifier, + }, + UnfreezeTokens { + actor_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + unfreeze_identity: Identifier, + }, + PauseTokens { + actor_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + }, + ResumeTokens { + actor_identity: QualifiedIdentity, + data_contract: DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + }, +} + +impl AppContext { + pub async fn run_token_task( + self: &Arc, + task: TokenTask, + sdk: &Sdk, + sender: mpsc::Sender, + ) -> Result { + match &task { + TokenTask::RegisterTokenContract { + identity, + signing_key, + token_name, + should_capitalize, + decimals, + base_supply, + max_supply, + start_paused, + keeps_history, + main_control_group, + manual_minting_rules, + manual_burning_rules, + freeze_rules, + unfreeze_rules, + destroy_frozen_funds_rules, + emergency_action_rules, + max_supply_change_rules, + conventions_change_rules, + main_control_group_change_authorized, + distribution_rules, + groups, + } => { + let data_contract = self + .build_data_contract_v1_with_one_token( + identity.identity.id().clone(), + token_name.clone(), + *should_capitalize, + *decimals, + *base_supply, + *max_supply, + *start_paused, + *keeps_history, + *main_control_group, + manual_minting_rules.clone(), + manual_burning_rules.clone(), + freeze_rules.clone(), + unfreeze_rules.clone(), + destroy_frozen_funds_rules.clone(), + emergency_action_rules.clone(), + max_supply_change_rules.clone(), + conventions_change_rules.clone(), + main_control_group_change_authorized.clone(), + distribution_rules.clone(), + groups.clone(), + ) + .map_err(|e| format!("Error building contract V1: {e}"))?; + + // 2) Call your existing function that registers the contract + self.register_data_contract( + data_contract, + token_name.clone(), + identity.clone(), + signing_key.clone(), + sdk, + ) + .await + .map(|_| { + BackendTaskSuccessResult::Message( + "Successfully registered token contract".to_string(), + ) + }) + .map_err(|e| format!("Failed to register token contract: {e}")) + } + TokenTask::QueryMyTokenBalances => self + .query_my_token_balances(sdk, sender) + .await + .map_err(|e| format!("Failed to fetch token balances: {e}")), + TokenTask::QueryTokensByKeyword(_query) => { + // Placeholder + Ok(BackendTaskSuccessResult::Message("QueryTokens".to_string())) + + // Actually do this + // self.query_tokens(query, sdk, sender).await + } + TokenTask::MintTokens { + sending_identity, + data_contract, + token_position, + signing_key, + amount, + recipient_id, + } => self + .mint_tokens( + sending_identity, + data_contract, + *token_position, + signing_key.clone(), + *amount, + recipient_id.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to mint tokens: {e}")), + TokenTask::QueryTokensByKeywordPage(_query, _cursor) => { + // Placeholder + Ok(BackendTaskSuccessResult::Message( + "QueryTokensByKeywordPage".to_string(), + )) + + // Actually do this + // self.query_tokens_page(query, cursor, sdk, sender).await + } + TokenTask::TransferTokens { + sending_identity, + recipient_id, + amount, + data_contract, + token_position, + signing_key, + } => self + .transfer_tokens( + &sending_identity, + *recipient_id, + *amount, + data_contract, + *token_position, + signing_key.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to transfer tokens: {e}")), + TokenTask::BurnTokens { + owner_identity, + data_contract, + token_position, + signing_key, + amount, + } => self + .burn_tokens( + owner_identity, + data_contract, + *token_position, + signing_key.clone(), + *amount, + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to burn tokens: {e}")), + TokenTask::DestroyFrozenFunds { + actor_identity, + data_contract, + token_position, + signing_key, + frozen_identity, + } => self + .destroy_frozen_funds( + actor_identity, + data_contract, + *token_position, + signing_key.clone(), + frozen_identity.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to destroy frozen funds: {e}")), + TokenTask::FreezeTokens { + actor_identity, + data_contract, + token_position, + signing_key, + freeze_identity, + } => self + .freeze_tokens( + actor_identity, + data_contract, + *token_position, + signing_key.clone(), + freeze_identity.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to freeze tokens: {e}")), + TokenTask::UnfreezeTokens { + actor_identity, + data_contract, + token_position, + signing_key, + unfreeze_identity, + } => self + .unfreeze_tokens( + actor_identity, + data_contract, + *token_position, + signing_key.clone(), + unfreeze_identity.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to unfreeze tokens: {e}")), + TokenTask::PauseTokens { + actor_identity, + data_contract, + token_position, + signing_key, + } => self + .pause_tokens( + actor_identity, + data_contract, + *token_position, + signing_key.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to pause tokens: {e}")), + TokenTask::ResumeTokens { + actor_identity, + data_contract, + token_position, + signing_key, + } => self + .resume_tokens( + actor_identity, + data_contract, + *token_position, + signing_key.clone(), + sdk, + sender, + ) + .await + .map_err(|e| format!("Failed to resume tokens: {e}")), + } + } + + /// Constructs a DataContract::V1 with: + /// - contract_id (random) + /// - version = 1 + /// - the specified owner_id + /// - an empty set of documents, groups, schema_defs + /// - a single token in tokens[0] with fields derived from your parameters. + pub fn build_data_contract_v1_with_one_token( + &self, + owner_id: Identifier, + token_name: String, + should_capitalize: bool, + decimals: u16, + base_supply: u64, + max_supply: Option, + start_as_paused: bool, + keeps_history: bool, + main_control_group: Option, + manual_minting_rules: ChangeControlRules, + manual_burning_rules: ChangeControlRules, + freeze_rules: ChangeControlRules, + unfreeze_rules: ChangeControlRules, + destroy_frozen_funds_rules: ChangeControlRules, + emergency_action_rules: ChangeControlRules, + max_supply_change_rules: ChangeControlRules, + conventions_change_rules: ChangeControlRules, + main_control_group_change_authorized: AuthorizedActionTakers, + distribution_rules: TokenDistributionRules, + groups: BTreeMap, + ) -> Result { + // 1) Create the V1 struct + let mut contract_v1 = DataContractV1 { + id: Identifier::random(), + version: 1, + owner_id, + document_types: BTreeMap::new(), + metadata: None, + config: DataContractConfig::default_for_version(self.platform_version)?, + schema_defs: None, + groups: BTreeMap::new(), + tokens: BTreeMap::new(), + }; + + // 2) Build a single TokenConfiguration in V0 format + let mut token_config_v0 = TokenConfigurationV0::default_most_restrictive(); + let TokenConfigurationConvention::V0(ref mut conv_v0) = token_config_v0.conventions; + conv_v0.decimals = decimals as u16; + conv_v0.localizations.insert( + "en".to_string(), + TokenConfigurationLocalizationsV0 { + should_capitalize, + singular_form: token_name.to_string(), + plural_form: format!("{}s", token_name), + }, + ); + token_config_v0.base_supply = base_supply; + token_config_v0.max_supply = max_supply; + token_config_v0.start_as_paused = start_as_paused; + token_config_v0.keeps_history = keeps_history; + token_config_v0.main_control_group = main_control_group; + token_config_v0.manual_minting_rules = manual_minting_rules; + token_config_v0.manual_burning_rules = manual_burning_rules; + token_config_v0.freeze_rules = freeze_rules; + token_config_v0.unfreeze_rules = unfreeze_rules; + token_config_v0.destroy_frozen_funds_rules = destroy_frozen_funds_rules; + token_config_v0.emergency_action_rules = emergency_action_rules; + token_config_v0.max_supply_change_rules = max_supply_change_rules; + token_config_v0.conventions_change_rules = conventions_change_rules; + token_config_v0.main_control_group_can_be_modified = main_control_group_change_authorized; + token_config_v0.distribution_rules = distribution_rules; + + // Wrap in the enum + let token_config = TokenConfiguration::V0(token_config_v0); + + // 7) Insert this token config at position 0 + contract_v1 + .tokens + .insert(TokenContractPosition::from(0u16), token_config); + + contract_v1.groups = groups; + + // 8) Wrap the whole struct in DataContract::V1 + Ok(DataContract::V1(contract_v1)) + } +} diff --git a/src/backend_task/tokens/pause_tokens.rs b/src/backend_task/tokens/pause_tokens.rs new file mode 100644 index 000000000..2c0ae5c48 --- /dev/null +++ b/src/backend_task/tokens/pause_tokens.rs @@ -0,0 +1,47 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::emergency_action::TokenEmergencyActionTransitionBuilder; +use dash_sdk::platform::{DataContract, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn pause_tokens( + &self, + actor_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + // Use .pause(...) constructor + let builder = TokenEmergencyActionTransitionBuilder::pause( + data_contract, + token_position, + actor_identity.identity.id(), + ); + + // Optionally chain .with_public_note(...).with_settings(...), etc. + + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Pause Tokens transition: {:?}", e))?; + + // Broadcast + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Pause Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message("PauseTokens".to_string())) + } +} diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs new file mode 100644 index 000000000..9ea58885b --- /dev/null +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -0,0 +1,96 @@ +//! Query token balances from Platform + +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::tokens::identity_token_balances::{ + IdentityTokenBalances, IdentityTokenBalancesQuery, +}; +use dash_sdk::platform::FetchMany; +use dash_sdk::{dpp::balances::credits::TokenAmount, Sdk}; +use tokio::sync::mpsc; + +use crate::app::TaskResult; +impl AppContext { + pub async fn query_my_token_balances( + &self, + sdk: &Sdk, + sender: mpsc::Sender, + ) -> Result { + let identities = self + .load_local_qualified_identities() + .map_err(|e| format!("Failed to load identities: {e}"))?; + + for identity in identities { + let identity_id = identity.identity.id(); + let token_infos = self + .identity_token_balances() + .map_err(|e| format!("Failed to load identity token balances: {e}"))? + .iter() + .filter(|t| t.identity_id == identity_id) + .map(|t| { + ( + t.token_identifier.clone(), + t.data_contract_id.clone(), + t.token_position, + ) + }) + .collect::>(); + + let token_ids = token_infos + .iter() + .map(|(token_id, _, _)| token_id.clone()) + .collect(); + + let query = IdentityTokenBalancesQuery { + identity_id, + token_ids, + }; + + let balances_result: Result = + TokenAmount::fetch_many(sdk, query) + .await + .map_err(|e| e.to_string()); + + match balances_result { + Ok(token_balances) => { + for balance in token_balances.iter() { + let token_id = balance.0; + let balance = match balance.1 { + Some(b) => *b, + None => 0, + }; + let associated_contract_and_position = token_infos + .iter() + .find(|(id, _, _)| id == token_id) + .expect("Expected to find associated contract and position"); + if let Err(e) = self.db.insert_identity_token_balance( + token_id, + &identity_id, + balance, + &associated_contract_and_position.1, + associated_contract_and_position.2, + self, + ) { + return Err(format!( + "Failed to insert token balance into local database: {:?}", + e + )); + }; + sender + .send(TaskResult::Refresh) + .await + .map_err(|e| format!("Failed to send refresh message after successful Platform query and local database insert: {:?}", e))?; + } + } + Err(e) => { + return Err(format!("Failed to query token balances: {:?}", e)); + } + } + } + + Ok(BackendTaskSuccessResult::Message( + "Successfully fetched token balances".to_string(), + )) + } +} diff --git a/src/backend_task/tokens/query_tokens.rs b/src/backend_task/tokens/query_tokens.rs new file mode 100644 index 000000000..6fc671469 --- /dev/null +++ b/src/backend_task/tokens/query_tokens.rs @@ -0,0 +1 @@ +//! Execute the token query on Platform diff --git a/src/backend_task/tokens/resume_tokens.rs b/src/backend_task/tokens/resume_tokens.rs new file mode 100644 index 000000000..87211f818 --- /dev/null +++ b/src/backend_task/tokens/resume_tokens.rs @@ -0,0 +1,49 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::emergency_action::TokenEmergencyActionTransitionBuilder; +use dash_sdk::platform::{DataContract, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn resume_tokens( + &self, + actor_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + // Use .resume(...) constructor + let builder = TokenEmergencyActionTransitionBuilder::resume( + data_contract, + token_position, + actor_identity.identity.id(), + ); + + // Optionally chain .with_public_note(...).with_settings(...), etc. + + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Resume Tokens transition: {:?}", e))?; + + // Broadcast + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Resume Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message( + "ResumeTokens".to_string(), + )) + } +} diff --git a/src/backend_task/tokens/transfer_tokens.rs b/src/backend_task/tokens/transfer_tokens.rs new file mode 100644 index 000000000..ca41cded9 --- /dev/null +++ b/src/backend_task/tokens/transfer_tokens.rs @@ -0,0 +1,54 @@ +//! Transfer tokens from one identity to another + +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::transfer::TokenTransferTransitionBuilder; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +use crate::app::TaskResult; +impl AppContext { + pub async fn transfer_tokens( + &self, + sending_identity: &QualifiedIdentity, + recipient_id: Identifier, + amount: u64, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + sdk: &Sdk, + sender: mpsc::Sender, + ) -> Result { + let builder = TokenTransferTransitionBuilder::new( + data_contract, + token_position, + sending_identity.identity.id(), + recipient_id, + amount, + ); + + let state_transition = builder + .sign(sdk, &signing_key, sending_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing token transfer state transition: {:?}", e))?; + + let _ = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| { + format!( + "Error broadcasting token transfer state transition: {:?}", + e.to_string() + ) + })?; + + Ok(BackendTaskSuccessResult::Message( + "TransferTokens".to_string(), + )) + } +} diff --git a/src/backend_task/tokens/unfreeze_tokens.rs b/src/backend_task/tokens/unfreeze_tokens.rs new file mode 100644 index 000000000..9cece80fc --- /dev/null +++ b/src/backend_task/tokens/unfreeze_tokens.rs @@ -0,0 +1,50 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::fungible_tokens::unfreeze::TokenUnfreezeTransitionBuilder; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; +use dash_sdk::Sdk; +use tokio::sync::mpsc; + +impl AppContext { + pub async fn unfreeze_tokens( + &self, + actor_identity: &QualifiedIdentity, + data_contract: &DataContract, + token_position: u16, + signing_key: IdentityPublicKey, + unfreeze_identity: Identifier, + sdk: &Sdk, + _sender: mpsc::Sender, + ) -> Result { + let builder = TokenUnfreezeTransitionBuilder::new( + data_contract, + token_position, + actor_identity.identity.id(), + unfreeze_identity, + ); + + // Optionally chain .with_public_note(...).with_settings(...), etc. + + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version) + .await + .map_err(|e| format!("Error signing Unfreeze Tokens transition: {:?}", e))?; + + // Broadcast + let _proof_result = state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| format!("Error broadcasting Unfreeze Tokens transition: {}", e))?; + + // Return success + Ok(BackendTaskSuccessResult::Message( + "UnfreezeTokens".to_string(), + )) + } +} diff --git a/src/components/core_zmq_listener.rs b/src/components/core_zmq_listener.rs index 353cb2915..3deb76cdd 100644 --- a/src/components/core_zmq_listener.rs +++ b/src/components/core_zmq_listener.rs @@ -63,7 +63,6 @@ impl CoreZMQListener { let should_stop = Arc::new(AtomicBool::new(false)); let endpoint = endpoint.to_string(); let should_stop_clone = Arc::clone(&should_stop); - let sender_clone = sender.clone(); let handle = thread::spawn(move || { // Create the socket inside the thread. @@ -147,7 +146,7 @@ impl CoreZMQListener { match Block::consensus_decode(&mut cursor) { Ok(block) => { // Send the ChainLock and Network back to the main thread - if let Err(e) = sender_clone.send(( + if let Err(e) = sender.send(( ZMQMessage::ChainLockedBlock(block), network, )) { @@ -179,7 +178,7 @@ impl CoreZMQListener { match InstantLock::consensus_decode(&mut cursor) { Ok(islock) => { // Send the Transaction, InstantLock, and Network back to the main thread - if let Err(e) = sender_clone.send(( + if let Err(e) = sender.send(( ZMQMessage::ISLockedTransaction( tx, islock, ), @@ -286,7 +285,6 @@ impl CoreZMQListener { let should_stop = Arc::new(AtomicBool::new(false)); let endpoint = endpoint.to_string(); let should_stop_clone = Arc::clone(&should_stop); - let sender_clone = sender.clone(); let handle = thread::spawn(move || { // Create the runtime inside the thread. @@ -336,7 +334,7 @@ impl CoreZMQListener { tx.send(ZMQConnectionEvent::Connected) .expect("Failed to send connected event"); } - if let Err(e) = sender_clone.send(( + if let Err(e) = sender.send(( ZMQMessage::ChainLockedBlock(block), network, )) { @@ -367,7 +365,7 @@ impl CoreZMQListener { tx.send(ZMQConnectionEvent::Connected) .expect("Failed to send connected event"); } - if let Err(e) = sender_clone.send(( + if let Err(e) = sender.send(( ZMQMessage::ISLockedTransaction( tx, islock, ), diff --git a/src/config.rs b/src/config.rs index 51e7288c1..eee6ecbd5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::io::Write; use std::str::FromStr; use crate::app_dir::app_user_data_file_path; @@ -8,22 +10,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize, Clone)] pub struct Config { - /// The mainnet network config pub mainnet_config: Option, - /// The testnet network config pub testnet_config: Option, -} - -impl Config { - pub fn config_for_network(&self, network: Network) -> &Option { - match network { - Network::Dash => &self.mainnet_config, - Network::Testnet => &self.testnet_config, - Network::Devnet => &None, - Network::Regtest => &None, - _ => &None, - } - } + pub devnet_config: Option, + pub local_config: Option, } #[derive(Debug, thiserror::Error)] @@ -57,11 +47,115 @@ pub struct NetworkConfig { } impl Config { + pub fn config_for_network(&self, network: Network) -> &Option { + match network { + Network::Dash => &self.mainnet_config, + Network::Testnet => &self.testnet_config, + Network::Devnet => &self.devnet_config, + Network::Regtest => &self.local_config, + _ => &None, + } + } + + /// Write the current configuration back to the `.env` file so that + /// subsequent calls to `Config::load()` will reflect changes. + pub fn save(&self) -> Result<(), ConfigError> { + let env_file_path = + app_user_data_file_path(".env").map_err(|e| ConfigError::LoadError(e.to_string()))?; + + // Create / truncate the `.env` file + let mut env_file = + File::create(&env_file_path).map_err(|e| ConfigError::LoadError(e.to_string()))?; + + // Helper function to write a single network config to the `.env` file + let mut write_network_config = |prefix: &str, config: &NetworkConfig| { + // Each line becomes e.g. MAINNET_dapi_addresses=... + // For "local" (regtest), you'll see LOCAL_dapi_addresses=... + // + // Use the environment variable scheme you prefer. Make sure it + // matches what `load()` expects (i.e. `envy::prefixed("MAINNET_")`, + // etc.). + + writeln!( + env_file, + "{}dapi_addresses={}", + prefix, config.dapi_addresses + ) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + writeln!(env_file, "{}core_host={}", prefix, config.core_host) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + writeln!(env_file, "{}core_rpc_port={}", prefix, config.core_rpc_port) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + writeln!(env_file, "{}core_rpc_user={}", prefix, config.core_rpc_user) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + writeln!( + env_file, + "{}core_rpc_password={}", + prefix, config.core_rpc_password + ) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + writeln!( + env_file, + "{}insight_api_url={}", + prefix, config.insight_api_url + ) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + + if let Some(devnet_name) = &config.devnet_name { + // Only write devnet name if it exists + writeln!(env_file, "{}devnet_name={}", prefix, devnet_name) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + } + if let Some(wallet_private_key) = &config.wallet_private_key { + writeln!( + env_file, + "{}wallet_private_key={}", + prefix, wallet_private_key + ) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + } + + // Whether or not to show in UI + writeln!(env_file, "{}show_in_ui={}", prefix, config.show_in_ui) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + + // Add a blank line after each config block + writeln!(env_file).map_err(|e| ConfigError::LoadError(e.to_string()))?; + + Ok(()) + }; + + // Mainnet + if let Some(ref mainnet_config) = self.mainnet_config { + // `envy::prefixed("MAINNET_")` expects these lines to start with "MAINNET_" + write_network_config("MAINNET_", mainnet_config)?; + } + + // Testnet + if let Some(ref testnet_config) = self.testnet_config { + write_network_config("TESTNET_", testnet_config)?; + } + + // Devnet + if let Some(ref devnet_config) = self.devnet_config { + write_network_config("DEVNET_", devnet_config)?; + } + + // Local (Regtest) + if let Some(ref local_config) = self.local_config { + // `envy::prefixed("LOCAL_")` expects "LOCAL_..." + write_network_config("LOCAL_", local_config)?; + } + + tracing::info!("Successfully saved configuration to {:?}", env_file_path); + Ok(()) + } + /// Loads the configuration for all networks from environment variables and `.env` file. pub fn load() -> Result { // Load the .env file if available let env_file_path = app_user_data_file_path(".env").expect("should create .env file path"); - if let Err(err) = dotenvy::from_path(env_file_path) { + if let Err(err) = dotenvy::from_path_override(env_file_path) { tracing::warn!( ?err, "Failed to load .env file. Continuing with environment variables." @@ -93,7 +187,33 @@ impl Config { } }; - if mainnet_config.is_none() && testnet_config.is_none() { + let devnet_config = match envy::prefixed("DEVNET_").from_env::() { + Ok(config) => { + tracing::info!("Devnet configuration loaded successfully"); + Some(config) + } + Err(err) => { + tracing::error!(?err, "Failed to load devnet configuration"); + None + } + }; + + let local_config = match envy::prefixed("LOCAL_").from_env::() { + Ok(config) => { + tracing::info!("Local configuration loaded successfully"); + Some(config) + } + Err(err) => { + tracing::error!(?err, "Failed to load local configuration"); + None + } + }; + + if mainnet_config.is_none() + && testnet_config.is_none() + && devnet_config.is_none() + && local_config.is_none() + { return Err(ConfigError::NoValidConfigs); } else if mainnet_config.is_none() { return Err(ConfigError::LoadError( @@ -103,13 +223,40 @@ impl Config { tracing::warn!( "Failed to load testnet configuration, but successfully loaded mainnet config" ); + } else if devnet_config.is_none() { + tracing::warn!( + "Failed to load devnet configuration, but successfully loaded mainnet config" + ); + } else if local_config.is_none() { + tracing::warn!( + "Failed to load local configuration, but successfully loaded mainnet config" + ); } Ok(Config { mainnet_config, testnet_config, + devnet_config, + local_config, }) } + + /// Update (overwrite) the configuration for a particular network. + pub fn update_config_for_network(&mut self, network: Network, new_config: NetworkConfig) { + match network { + Network::Dash => self.mainnet_config = Some(new_config), + Network::Testnet => self.testnet_config = Some(new_config), + Network::Devnet => self.devnet_config = Some(new_config), + Network::Regtest => self.local_config = Some(new_config), + _ => { + // Optionally handle any custom or unknown network here if needed + tracing::warn!( + "Attempted to update config for an unknown network: {:?}", + network + ); + } + } + } } impl NetworkConfig { @@ -124,11 +271,18 @@ impl NetworkConfig { /// List of DAPI addresses pub fn dapi_address_list(&self) -> AddressList { - AddressList::from(self.dapi_addresses.as_str()) + AddressList::from_str(&self.dapi_addresses).expect("Could not parse DAPI addresses") } /// Insight API URI pub fn insight_api_uri(&self) -> Uri { Uri::from_str(&self.insight_api_url).expect("invalid insight API URL") } + + /// Update just the `core_rpc_password` in a builder-like manner. + /// Returns a new `NetworkConfig` with the updated password. + pub fn update_core_rpc_password(mut self, new_password: String) -> Self { + self.core_rpc_password = new_password; + self + } } diff --git a/src/context.rs b/src/context.rs index 27b955c58..6f8c5e838 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,3 +1,4 @@ +use crate::app_dir::core_cookie_path; use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::config::{Config, NetworkConfig}; @@ -9,6 +10,7 @@ use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::sdk_wrapper::initialize_sdk; +use crate::ui::tokens::tokens_screen::IdentityTokenBalance; use crate::ui::RootScreenType; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::dashcore_rpc::dashcore::{InstantLock, Transaction}; @@ -36,14 +38,15 @@ pub struct AppContext { pub(crate) developer_mode: bool, pub(crate) devnet_name: Option, pub(crate) db: Arc, - pub(crate) sdk: Sdk, - pub(crate) config: NetworkConfig, + pub(crate) sdk: RwLock, + pub(crate) config: RwLock, pub(crate) rx_zmq_status: Receiver, pub(crate) sx_zmq_status: Sender, pub(crate) zmq_connection_status: Mutex, pub(crate) dpns_contract: Arc, pub(crate) withdraws_contract: Arc, - pub(crate) core_client: Client, + pub(crate) token_history_contract: Arc, + pub(crate) core_client: RwLock, pub(crate) has_wallet: AtomicBool, pub(crate) wallets: RwLock>>>, pub(crate) password_info: Option, @@ -70,7 +73,7 @@ impl AppContext { // we create provider, but we need to set app context to it later, as we have a circular dependency let provider = - Provider::new(db.clone(), &network_config).expect("Failed to initialize SDK"); + Provider::new(db.clone(), network, &network_config).expect("Failed to initialize SDK"); let sdk = initialize_sdk(&network_config, network, provider.clone()); @@ -82,18 +85,36 @@ impl AppContext { load_system_data_contract(SystemDataContract::Withdrawals, PlatformVersion::latest()) .expect("expected to get withdrawal contract"); + let token_history_contract = + load_system_data_contract(SystemDataContract::TokenHistory, PlatformVersion::latest()) + .expect("expected to get token history contract"); + let addr = format!( "http://{}:{}", network_config.core_host, network_config.core_rpc_port ); - let core_client = Client::new( - &addr, - Auth::UserPass( - network_config.core_rpc_user.to_string(), - network_config.core_rpc_password.to_string(), - ), - ) - .ok()?; + let cookie_path = core_cookie_path(network, &network_config.devnet_name) + .expect("expected to get cookie path"); + + // Try cookie authentication first + let core_client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { + Ok(client) => Ok(client), + Err(_) => { + // If cookie auth fails, try user/password authentication + tracing::info!( + "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + cookie_path, + ); + Client::new( + &addr, + Auth::UserPass( + network_config.core_rpc_user.to_string(), + network_config.core_rpc_password.to_string(), + ), + ) + } + } + .expect("Failed to create CoreClient"); let wallets: BTreeMap<_, _> = db .get_wallets(&network) @@ -107,13 +128,14 @@ impl AppContext { developer_mode: false, devnet_name: None, db, - sdk, - config: network_config, + sdk: sdk.into(), + config: network_config.into(), sx_zmq_status, rx_zmq_status, dpns_contract: Arc::new(dpns_contract), withdraws_contract: Arc::new(withdrawal_contract), - core_client, + token_history_contract: Arc::new(token_history_contract), + core_client: core_client.into(), has_wallet: (!wallets.is_empty()).into(), wallets: RwLock::new(wallets), password_info, @@ -128,12 +150,56 @@ impl AppContext { Some(app_context) } + /// Rebuild both the Dash RPC `core_client` and the `Sdk` using the + /// updated `NetworkConfig` from `self.config`. + pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), String> { + // 1. Grab a fresh snapshot of your NetworkConfig + let cfg = { + let cfg_lock = self.config.read().unwrap(); + cfg_lock.clone() + }; + + // 2. Rebuild the RPC client with the new password + let addr = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); + let new_client = dash_sdk::dashcore_rpc::Client::new( + &addr, + dash_sdk::dashcore_rpc::Auth::UserPass( + cfg.core_rpc_user.clone(), + cfg.core_rpc_password.clone(), + ), + ) + .map_err(|e| format!("Failed to create new Core RPC client: {e}"))?; + + // 3. Rebuild the Sdk with the updated config + let provider = Provider::new(self.db.clone(), self.network, &cfg) + .map_err(|e| format!("Failed to init provider: {e}"))?; + let new_sdk = initialize_sdk(&cfg, self.network, provider.clone()); + + // 4. Swap them in + { + let mut client_lock = self + .core_client + .write() + .expect("Core client lock was poisoned"); + *client_lock = new_client; + } + { + let mut sdk_lock = self.sdk.write().unwrap(); + *sdk_lock = new_sdk; + } + + // Rebind the provider to the new app context + provider.bind_app_context(self.clone()); + + Ok(()) + } + pub(crate) fn network_string(&self) -> String { match self.network { Network::Dash => "dash".to_string(), Network::Testnet => "testnet".to_string(), Network::Devnet => format!("devnet:{}", self.devnet_name.clone().unwrap_or_default()), - Network::Regtest => "regtest".to_string(), + Network::Regtest => "local".to_string(), _ => "unknown".to_string(), } } @@ -170,6 +236,11 @@ impl AppContext { self.db.set_alias(identifier, new_alias) } + /// Gets the alias for an identity + pub fn get_alias(&self, identifier: &Identifier) -> Result> { + self.db.get_alias(identifier) + } + /// This is for before we know if Platform will accept the identity pub fn insert_local_qualified_identity_in_creation( &self, @@ -458,4 +529,16 @@ impl AppContext { Ok(()) } + + pub fn identity_token_balances(&self) -> Result> { + self.db.get_identity_token_balances(self) + } + + pub fn remove_token_balance( + &self, + token_id: Identifier, + identity_id: Identifier, + ) -> Result<()> { + self.db.remove_token_balance(&token_id, &identity_id, self) + } } diff --git a/src/context_provider.rs b/src/context_provider.rs index 0c5968615..cfd6a6169 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -1,7 +1,9 @@ +use crate::app_dir::core_cookie_path; use crate::config::NetworkConfig; use crate::context::AppContext; use crate::database::Database; use dash_sdk::core::LowLevelDashCoreClient as CoreClient; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::error::ContextProviderError; use dash_sdk::platform::ContextProvider; @@ -19,14 +21,32 @@ impl Provider { /// Create new ContextProvider. /// /// Note that you have to bind it to app context using [Provider::set_app_context()]. - pub fn new(db: Arc, config: &NetworkConfig) -> Result { - let core_client = CoreClient::new( - &config.core_host, - config.core_rpc_port, - &config.core_rpc_user, - &config.core_rpc_password, - ) - .map_err(|e| e.to_string())?; + pub fn new( + db: Arc, + network: Network, + config: &NetworkConfig, + ) -> Result { + let cookie_path = + core_cookie_path(network, &config.devnet_name).expect("Failed to get core cookie path"); + + // Read the cookie from disk + let cookie = std::fs::read_to_string(cookie_path); + let (user, pass) = if let Ok(cookie) = cookie { + // split the cookie at ":", first part is user (__cookie__), second part is password + let cookie_parts: Vec<&str> = cookie.split(':').collect(); + let user = cookie_parts[0]; + let password = cookie_parts[1]; + (user.to_string(), password.to_string()) + } else { + // Fall back to the pre-set user / pass if needed + ( + config.core_rpc_user.clone(), + config.core_rpc_password.clone(), + ) + }; + + let core_client = CoreClient::new(&config.core_host, config.core_rpc_port, &user, &pass) + .map_err(|e| e.to_string())?; Ok(Self { db, @@ -42,7 +62,8 @@ impl Provider { ac.replace(cloned); drop(ac); - app_context.sdk.set_context_provider(self.clone()); + let sdk = app_context.sdk.write().expect("lock poisoned"); + sdk.set_context_provider(self.clone()); } } @@ -58,6 +79,8 @@ impl ContextProvider for Provider { if data_contract_id == &app_ctx.dpns_contract.id() { Ok(Some(app_ctx.dpns_contract.clone())) + } else if data_contract_id == &app_ctx.token_history_contract.id() { + Ok(Some(app_ctx.token_history_contract.clone())) } else { let dc = self .db diff --git a/src/database/contracts.rs b/src/database/contracts.rs index f2b979b39..3063e00a1 100644 --- a/src/database/contracts.rs +++ b/src/database/contracts.rs @@ -2,8 +2,11 @@ use crate::context::AppContext; use crate::database::Database; use crate::model::qualified_contract::QualifiedContract; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::DataContract; use dash_sdk::dpp::identifier::Identifier; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::serialization::{ PlatformDeserializableWithPotentialValidationFromVersionedStructure, PlatformSerializableWithPlatformVersion, @@ -29,6 +32,39 @@ impl Database { "INSERT OR IGNORE INTO contract (contract_id, contract, name, network) VALUES (?, ?, ?, ?)", params![contract_id, contract_bytes, contract_name, network], )?; + + // Next, if the contract has tokens, add the tokens to identity_token_balances + if !data_contract.tokens().is_empty() { + for token in data_contract.tokens().iter() { + let wallets = app_context.wallets.read().unwrap(); + let identities = self.get_local_qualified_identities(app_context, &wallets)?; + if let Some(token_id) = data_contract.token_id(*token.0) { + for identity in identities { + let balance = if data_contract.owner_id() == identity.identity.id() { + token.1.base_supply() + } else { + 0 + }; + let _ = self + .insert_identity_token_balance( + &token_id, + &identity.identity.id(), + balance, + &data_contract.id(), + *token.0, + app_context, + ) + .map_err(|e| { + format!( + "Failed to insert token balance into local database: {:?}", + e + ) + }); + } + } + } + } + Ok(()) } @@ -192,10 +228,24 @@ impl Database { ) -> rusqlite::Result<()> { let network = app_context.network_string(); let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM contract WHERE contract_id = ? AND network = ?", - rusqlite::params![contract_id, network], + let tx = conn.unchecked_transaction()?; + + // 1) remove identity token balances for that contract + tx.execute( + "DELETE FROM identity_token_balances + WHERE data_contract_id = ? AND network = ?", + params![contract_id, network], + )?; + + // 2) remove the contract itself + tx.execute( + "DELETE FROM contract + WHERE contract_id = ? AND network = ?", + params![contract_id, network], )?; + + tx.commit()?; + Ok(()) } } diff --git a/src/database/identities.rs b/src/database/identities.rs index 79d88b172..e2ef274cd 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -29,6 +29,17 @@ impl Database { Ok(()) } + + pub fn get_alias(&self, identifier: &Identifier) -> rusqlite::Result> { + let id = identifier.to_vec(); + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare("SELECT alias FROM identity WHERE id = ?")?; + let alias: Option = stmt.query_row(params![id], |row| row.get(0)).ok(); + + Ok(alias) + } + pub fn insert_local_qualified_identity( &self, qualified_identity: &QualifiedIdentity, @@ -285,8 +296,8 @@ impl Database { "CREATE TABLE IF NOT EXISTS identity_order ( pos INTEGER NOT NULL, identity_id BLOB NOT NULL, - PRIMARY KEY(pos) - )", + PRIMARY KEY(pos), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE)", )?; Ok(()) } @@ -317,33 +328,54 @@ impl Database { Ok(()) } - /// Loads the custom identity order from the DB, returning a list of Identifiers in the stored order. - /// If there's no data, returns an empty Vec. + /// Loads the user’s custom identity order (the entire list). + /// If an identity in the order doesn't exist in the identity table, it is removed. pub fn load_identity_order(&self) -> rusqlite::Result> { - // Make sure table exists (in case it doesn't) + // Make sure table exists self.ensure_identity_order_table_exists()?; let conn = self.conn.lock().unwrap(); // Read all rows sorted by pos - let mut stmt = conn.prepare( - "SELECT identity_id FROM identity_order - ORDER BY pos ASC", - )?; + let mut stmt = conn.prepare("SELECT identity_id FROM identity_order ORDER BY pos ASC")?; let mut rows = stmt.query([])?; - let mut result = Vec::new(); + let mut final_list = Vec::new(); + let mut to_remove = Vec::new(); while let Some(row) = rows.next()? { let id_bytes: Vec = row.get(0)?; // Convert from raw bytes to an Identifier - if let Ok(identifier) = Identifier::from_vec(id_bytes) { - result.push(identifier); + let identifier = match Identifier::from_vec(id_bytes.clone()) { + Ok(id) => id, + Err(_) => { + // If parsing as an Identifier fails, queue for removal + to_remove.push(id_bytes); + continue; + } + }; + + // Check if the identity is still in 'identity' table + let mut check_stmt = + conn.prepare("SELECT EXISTS(SELECT 1 FROM identity WHERE id = ?)")?; + let exists: i64 = check_stmt.query_row(params![identifier.to_vec()], |r| r.get(0))?; + if exists == 1 { + // Keep it + final_list.push(identifier); } else { - // If for some reason it fails to parse, skip it or handle error + // Queue for removal because it doesn't exist in the identity table + to_remove.push(identifier.to_vec()); } } - Ok(result) + // Remove any “dangling” references + for id in to_remove { + conn.execute( + "DELETE FROM identity_order WHERE identity_id = ?", + params![id], + )?; + } + + Ok(final_list) } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 4caef82bc..05cf27dd2 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -6,6 +6,7 @@ mod initialization; mod proof_log; mod scheduled_votes; mod settings; +mod tokens; mod top_ups; mod utxo; mod wallet; diff --git a/src/database/tokens.rs b/src/database/tokens.rs new file mode 100644 index 000000000..91afe1bc3 --- /dev/null +++ b/src/database/tokens.rs @@ -0,0 +1,250 @@ +use dash_sdk::{ + dpp::{ + data_contract::{ + accessors::v1::DataContractV1Getters, + associated_token::token_configuration_convention::TokenConfigurationConvention, + }, + platform_value::string_encoding::Encoding, + }, + platform::Identifier, +}; +use rusqlite::params; + +use crate::{context::AppContext, ui::tokens::tokens_screen::IdentityTokenBalance}; + +use super::Database; + +impl Database { + /// Creates the identity_token_balances table if it doesn't already exist + fn ensure_identity_token_balances_table_exists(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS identity_token_balances ( + token_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + balance INTEGER NOT NULL, + data_contract_id BLOB NOT NULL, + token_position INTEGER NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY(token_id, identity_id, network) + )", + )?; + Ok(()) + } + + pub fn insert_identity_token_balance( + &self, + token_identifier: &Identifier, + identity_id: &Identifier, + balance: u64, + data_contract_id: &Identifier, + token_position: u16, + app_context: &AppContext, + ) -> rusqlite::Result<()> { + self.ensure_identity_token_balances_table_exists()?; + + let network = app_context.network_string(); + let token_identifier_vec = token_identifier.to_vec(); + let identity_id_vec = identity_id.to_vec(); + let data_contract_id_vec = data_contract_id.to_vec(); + + self.execute( + "INSERT OR REPLACE INTO identity_token_balances + (token_id, identity_id, balance, data_contract_id, token_position, network) + VALUES (?, ?, ?, ?, ?, ?)", + params![ + token_identifier_vec, + identity_id_vec, + balance, + data_contract_id_vec, + token_position, + network + ], + )?; + + Ok(()) + } + + pub fn get_identity_token_balances( + &self, + app_context: &AppContext, + ) -> rusqlite::Result> { + let network = app_context.network_string(); + + // 1) Lock and read everything into memory first. + let rows_data = { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT * + FROM identity_token_balances + WHERE network = ?", + )?; + + // Map all the rows into a Vec in memory. + let rows = stmt.query_map(params![network], |row| { + Ok(( + Identifier::from_vec(row.get(0)?), + Identifier::from_vec(row.get(1)?), + row.get(2)?, + Identifier::from_vec(row.get(3)?), + row.get(4)?, + )) + })?; + + let mut temp = Vec::new(); + for row in rows { + temp.push(row?); + } + temp + }; // <- The lock is dropped here because `conn` goes out of scope. + + let mut result = Vec::new(); + for (token_identifier, identity_id, balance, data_contract_id, token_position) in rows_data + { + let token_name = match self.get_contract_by_id( + data_contract_id + .clone() + .expect("Expected to convert data_contract_id from vec to Identifier"), + app_context, + ) { + Ok(Some(qualified_contract)) => { + let token_configuration = qualified_contract + .contract + .expected_token_configuration(token_position) + .expect("Expected to get token configuration") + .as_cow_v0(); + let conventions = match &token_configuration.conventions { + TokenConfigurationConvention::V0(conventions) => conventions, + }; + match conventions + .localizations + .get("en") + .map(|l| l.singular_form.clone()) + { + Some(token_name) => token_name, + None => token_identifier + .clone() + .expect("Expected to convert token_identifier from vec to Identifier") + .to_string(Encoding::Base58), + } + } + _ => token_identifier + .clone() + .expect("Expected to convert identity_id from vec to Identifier") + .to_string(Encoding::Base58), + }; + let identity_token_balance = IdentityTokenBalance { + token_identifier: token_identifier + .clone() + .expect("Expected to convert token_identifier from vec to Identifier"), + token_name, + identity_id: identity_id + .expect("Expected to convert identity_id from vec to Identifier"), + balance, + data_contract_id: data_contract_id + .expect("Expected to convert data_contract_id from vec to Identifier"), + token_position, + }; + result.push(identity_token_balance); + } + + Ok(result) + } + + pub fn remove_token_balance( + &self, + token_identifier: &Identifier, + identity_id: &Identifier, + app_context: &AppContext, + ) -> rusqlite::Result<()> { + let network = app_context.network_string(); + let token_identifier_vec = token_identifier.to_vec(); + let identity_id_vec = identity_id.to_vec(); + + self.execute( + "DELETE FROM identity_token_balances + WHERE token_id = ? AND identity_id = ? AND network = ?", + params![token_identifier_vec, identity_id_vec, network], + )?; + + Ok(()) + } + + /// Creates the identity_order table if it doesn't already exist + /// with two columns: `pos` (int) and `identity_id` (blob). + /// pos is the "position" in the custom ordering. + fn ensure_token_order_table_exists(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS token_order ( + pos INTEGER NOT NULL, + token_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + PRIMARY KEY(pos) + )", + )?; + Ok(()) + } + + /// Saves the user’s custom identity order (the entire list). + /// This method overwrites whatever was there before. + pub fn save_token_order(&self, all_ids: Vec<(Identifier, Identifier)>) -> rusqlite::Result<()> { + // Make sure table exists + self.ensure_token_order_table_exists()?; + + let conn = self.conn.lock().unwrap(); + let tx = conn.unchecked_transaction()?; + + // Clear existing rows + tx.execute("DELETE FROM token_order", [])?; + + // Insert each ID with a numeric pos = 0..N + for (pos, (token_id, identity_id)) in all_ids.iter().enumerate() { + let token_id_bytes = token_id.to_vec(); + let identity_id_bytes = identity_id.to_vec(); + tx.execute( + "INSERT INTO token_order (pos, token_id, identity_id) + VALUES (?1, ?2, ?3)", + params![pos as i64, token_id_bytes, identity_id_bytes], + )?; + } + + tx.commit()?; + Ok(()) + } + + /// Loads the custom identity order from the DB, returning a list of Identifiers in the stored order. + /// If there's no data, returns an empty Vec. + pub fn load_token_order(&self) -> rusqlite::Result> { + // Make sure table exists (in case it doesn't) + self.ensure_token_order_table_exists()?; + + let conn = self.conn.lock().unwrap(); + + // Read all rows sorted by pos + let mut stmt = conn.prepare( + "SELECT token_id, identity_id FROM token_order + ORDER BY pos ASC", + )?; + + let mut rows = stmt.query([])?; + let mut result = Vec::new(); + + while let Some(row) = rows.next()? { + let token_id_bytes: Vec = row.get(0)?; + let identity_id_bytes: Vec = row.get(1)?; + // Convert from raw bytes to an Identifier + if let Ok(token_id) = Identifier::from_vec(token_id_bytes) { + if let Ok(identity_id) = Identifier::from_vec(identity_id_bytes) { + result.push((token_id, identity_id)); + } else { + // If for some reason it fails to parse, skip it or handle error + } + } else { + // If for some reason it fails to parse, skip it or handle error + } + } + + Ok(result) + } +} diff --git a/src/logging.rs b/src/logging.rs index c6359f665..98dfe1df2 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -12,7 +12,7 @@ pub fn initialize_logger() { }; let filter = EnvFilter::try_new( - "error,dash_sdk=debug,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug", + "error,info,debug,dash_sdk=debug,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug", ) .unwrap_or_else(|e| panic!("Failed to create EnvFilter: {:?}", e)); diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index f1cb50abc..13acbc712 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -347,6 +347,19 @@ impl QualifiedIdentity { keys } + + pub fn available_authentication_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { + let mut keys = vec![]; + + // Check the main identity's public keys + for (_, public_key) in self.private_keys.identity_public_keys() { + if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION { + keys.push(public_key); + } + } + + keys + } } impl From for QualifiedIdentity { diff --git a/src/model/qualified_identity/qualified_identity_public_key.rs b/src/model/qualified_identity/qualified_identity_public_key.rs index 6c973d311..f14f6f122 100644 --- a/src/model/qualified_identity/qualified_identity_public_key.rs +++ b/src/model/qualified_identity/qualified_identity_public_key.rs @@ -1,8 +1,10 @@ use crate::model::qualified_identity::encrypted_key_storage::WalletDerivationPath; use crate::model::wallet::Wallet; use bincode::{Decode, Encode}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; +use dash_sdk::dpp::dashcore::{Address, PublicKey}; +use dash_sdk::dpp::{ + dashcore::Network, identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, +}; use dash_sdk::platform::IdentityPublicKey; use std::sync::{Arc, RwLock}; @@ -39,19 +41,22 @@ impl QualifiedIdentityPublicKey { // Initialize `in_wallet_at_derivation_path` as `None` let mut in_wallet_at_derivation_path = None; - if let Ok(address) = value.address(network) { - // Iterate over each wallet to check for matching derivation paths - for locked_wallet in wallets { - let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } + let pubkey = + PublicKey::from_slice(value.data().as_slice()).expect("Expected valid public key"); + + let address = Address::p2pkh(&pubkey, network); + + // Iterate over each wallet to check for matching derivation paths + for locked_wallet in wallets { + let wallet = locked_wallet.read().unwrap(); + if let Some(derivation_path) = wallet.known_addresses.get(&address) { + in_wallet_at_derivation_path = Some(WalletDerivationPath { + wallet_seed_hash: wallet.seed_hash(), + derivation_path: derivation_path.clone(), + }); + } + if in_wallet_at_derivation_path.is_some() { + break; } } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 0a2616941..0d9058a76 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -420,6 +420,8 @@ impl Wallet { let address = Address::p2pkh(&public_key, network); app_context .core_client + .read() + .expect("Core client lock was poisoned") .import_address( &address, Some( diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index 2b8e78770..aee2c73a5 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -21,7 +21,7 @@ pub fn initialize_sdk( }; let sdk = SdkBuilder::new(address_list) - .with_version(PlatformVersion::get(4).unwrap()) + .with_version(PlatformVersion::latest()) .with_network(network) .with_context_provider(context_provider) .with_settings(request_settings) diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index 056f4a6c7..f8d9882ab 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -4,18 +4,21 @@ use crate::backend_task::BackendTask; use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; use crate::ui::contracts_documents::document_query_screen::DOCUMENT_PRIVATE_FIELDS; +use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::data_contract::document_type::Index; use dash_sdk::dpp::data_contract::{ accessors::v0::DataContractV0Getters, document_type::DocumentType, }; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use egui::{Color32, Context, Frame, Margin, RichText, SidePanel}; +use egui::{Color32, Context as EguiContext, Frame, Margin, RichText, SidePanel}; use std::collections::HashMap; use std::sync::Arc; pub fn add_contract_chooser_panel( - ctx: &Context, + ctx: &EguiContext, current_search_term: &mut String, app_context: &Arc, selected_data_contract: &mut QualifiedContract, @@ -27,11 +30,13 @@ pub fn add_contract_chooser_panel( ) -> AppAction { let mut action = AppAction::None; + // Retrieve the list of known contracts let contracts = app_context.get_contracts(None, None).unwrap_or_else(|e| { eprintln!("Error fetching contracts: {}", e); vec![] }); + // Filter contracts by name or ID let filtered_contracts: Vec<_> = contracts .iter() .filter(|contract| { @@ -46,181 +51,276 @@ pub fn add_contract_chooser_panel( .collect(); SidePanel::left("contract_chooser_panel") + // Let the user resize this panel horizontally + .resizable(true) .default_width(250.0) .frame( Frame::none() .fill(ctx.style().visuals.panel_fill) .inner_margin(Margin::same(10.0)), ) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label("Filter contracts:"); - ui.text_edit_singleline(current_search_term); - }); + .show(ctx, |panel_ui| { + // Make the whole panel scrollable (if it overflows vertically) + egui::ScrollArea::vertical().show(panel_ui, |ui| { + // Search box + ui.horizontal(|ui| { + ui.label("Filter contracts:"); + ui.text_edit_singleline(current_search_term); + }); - ui.separator(); - - ui.vertical(|ui| { - for contract in filtered_contracts { - ui.horizontal(|ui| { - let is_selected_contract = *selected_data_contract == *contract; - - let name_or_id = contract - .alias - .clone() - .unwrap_or(contract.contract.id().to_string(Encoding::Base58)); - - let contract_header_text = if is_selected_contract { - RichText::new(name_or_id).color(Color32::from_rgb(21, 101, 192)) - } else { - RichText::new(name_or_id) - }; - - ui.collapsing(contract_header_text, |ui| { - for (doc_name, doc_type) in contract.contract.document_types() { - let is_selected_doc_type = *selected_document_type == *doc_type; - - let doc_type_header_text = if is_selected_doc_type { - RichText::new(doc_name.clone()) - .color(Color32::from_rgb(21, 101, 192)) - } else { - RichText::new(doc_name.clone()) - }; - - let doc_resp = ui.collapsing(doc_type_header_text, |ui| { - // Display indexes as collapsible items - if doc_type.indexes().is_empty() { - ui.label("No indexes defined"); - } else { - for (index_name, index) in doc_type.indexes() { - let is_selected_index = - *selected_index == Some(index.clone()); + // List out each matching contract + ui.vertical(|ui| { + for contract in filtered_contracts { + ui.horizontal(|ui| { + let is_selected_contract = *selected_data_contract == *contract; + + let name_or_id = contract + .alias + .clone() + .unwrap_or(contract.contract.id().to_string(Encoding::Base58)); + + // Highlight the contract if selected + let contract_header_text = if is_selected_contract { + RichText::new(name_or_id).color(Color32::from_rgb(21, 101, 192)) + } else { + RichText::new(name_or_id) + }; + + // Expand/collapse the contract info + ui.collapsing(contract_header_text, |ui| { + // + // ===== Document Types Section ===== + // + ui.collapsing("Document Types", |ui| { + for (doc_name, doc_type) in contract.contract.document_types() { + let is_selected_doc_type = + *selected_document_type == *doc_type; + + let doc_type_header_text = if is_selected_doc_type { + RichText::new(doc_name.clone()) + .color(Color32::from_rgb(21, 101, 192)) + } else { + RichText::new(doc_name.clone()) + }; - let index_header_text = if is_selected_index { - RichText::new(format!("Index: {}", index_name)) - .color(Color32::from_rgb(21, 101, 192)) + let doc_resp = ui.collapsing(doc_type_header_text, |ui| { + // Show the indexes + if doc_type.indexes().is_empty() { + ui.label("No indexes defined"); } else { - RichText::new(format!("Index: {}", index_name)) - }; - - let index_resp = - ui.collapsing(index_header_text, |ui| { - // Show index properties if expanded - for prop in &index.properties { - ui.label(format!("{:?}", prop)); - } - }); + for (index_name, index) in doc_type.indexes() { + let is_selected_index = + *selected_index == Some(index.clone()); - // Handle toggling of index - // If the index is selected (expanded), build a WHERE clause for all properties: - if index_resp.header_response.clicked() - && index_resp.body_response.is_some() - { - *selected_index = Some(index.clone()); - if let Ok(new_doc_type) = contract - .contract - .document_type_cloned_for_name(&doc_name) - { - *selected_document_type = new_doc_type; - *selected_data_contract = contract.clone(); - - // Build the WHERE clause using all property names - let conditions: Vec = index - .property_names() - .iter() - .map(|property_name| { - format!("`{}` = '___'", property_name) - }) - .collect(); - - let where_clause = if conditions.is_empty() { - String::new() + let index_header_text = if is_selected_index { + RichText::new(format!( + "Index: {}", + index_name + )) + .color(Color32::from_rgb(21, 101, 192)) } else { - format!( - " WHERE {}", - conditions.join(" AND ") - ) + RichText::new(format!( + "Index: {}", + index_name + )) }; - *document_query = format!( - "SELECT * FROM {}{}", - selected_document_type.name(), - where_clause - ); + let index_resp = + ui.collapsing(index_header_text, |ui| { + // Show index properties if expanded + for prop in &index.properties { + ui.label(format!("{:?}", prop)); + } + }); + + // If index was just clicked (opened) + if index_resp.header_response.clicked() + && index_resp.body_response.is_some() + { + *selected_index = Some(index.clone()); + if let Ok(new_doc_type) = contract + .contract + .document_type_cloned_for_name( + &doc_name, + ) + { + *selected_document_type = new_doc_type; + *selected_data_contract = + contract.clone(); + + // Build the WHERE clause using all property names + let conditions: Vec = index + .property_names() + .iter() + .map(|property_name| { + format!( + "`{}` = '___'", + property_name + ) + }) + .collect(); + + let where_clause = + if conditions.is_empty() { + String::new() + } else { + format!( + " WHERE {}", + conditions.join(" AND ") + ) + }; + + *document_query = format!( + "SELECT * FROM {}{}", + selected_document_type.name(), + where_clause + ); + } + } + // If index was just collapsed + else if index_resp.header_response.clicked() + && index_resp.body_response.is_none() + { + *selected_index = None; + *document_query = format!( + "SELECT * FROM {}", + selected_document_type.name() + ); + } } - } else if index_resp.header_response.clicked() - && index_resp.body_response.is_none() + } + }); + + // Document Type clicked + if doc_resp.header_response.clicked() + && doc_resp.body_response.is_some() + { + // Expand doc type + if let Ok(new_doc_type) = contract + .contract + .document_type_cloned_for_name(&doc_name) { - // Index closed (collapsed) + *pending_document_type = new_doc_type.clone(); + *selected_document_type = new_doc_type.clone(); + *selected_data_contract = contract.clone(); *selected_index = None; - // Rebuild the query without index constraint *document_query = format!( "SELECT * FROM {}", selected_document_type.name() ); + + // Reinitialize field selection + pending_fields_selection.clear(); + + // Mark doc-defined fields + for (field_name, _schema) in + new_doc_type.properties().iter() + { + pending_fields_selection + .insert(field_name.clone(), true); + } + // Show "internal" fields as unchecked by default + for dash_field in DOCUMENT_PRIVATE_FIELDS { + pending_fields_selection + .insert(dash_field.to_string(), false); + } } } + // Document Type collapsed + else if doc_resp.header_response.clicked() + && doc_resp.body_response.is_none() + { + *selected_index = None; + *document_query = format!( + "SELECT * FROM {}", + selected_document_type.name() + ); + } } }); - // Check doc type toggling - if doc_resp.header_response.clicked() - && doc_resp.body_response.is_some() - { - if let Ok(new_doc_type) = - contract.contract.document_type_cloned_for_name(&doc_name) - { - *pending_document_type = new_doc_type.clone(); - *selected_document_type = new_doc_type.clone(); - *selected_data_contract = contract.clone(); - *selected_index = None; - *document_query = format!( - "SELECT * FROM {}", - selected_document_type.name() - ); - - // Now reinitialize the field selection - pending_fields_selection.clear(); - - // 1) Mark doc-type-defined fields as checked = true - for (field_name, _schema) in - new_doc_type.properties().iter() - { - pending_fields_selection - .insert(field_name.clone(), true); + // + // ===== Tokens Section ===== + // + ui.collapsing("Tokens", |ui| { + let tokens_map = contract.contract.tokens(); + if tokens_map.is_empty() { + ui.label("No tokens defined for this contract."); + } else { + for (token_name, token) in tokens_map { + // Each token is its own collapsible + ui.collapsing(token_name.to_string(), |ui| { + // Now you can display base supply, max supply, etc. + ui.label(format!( + "Base Supply: {}", + token.base_supply() + )); + if let Some(max_supply) = token.max_supply() { + ui.label(format!("Max Supply: {}", max_supply)); + } else { + ui.label("Max Supply: None"); + } + + // Add more details here + }); } - for dash_field in DOCUMENT_PRIVATE_FIELDS { - pending_fields_selection - .insert(dash_field.to_string(), false); + } + }); + + // + // ===== Entire Contract JSON ===== + // + ui.collapsing("Contract JSON", |ui| { + match contract.contract.to_json(app_context.platform_version) { + Ok(json_value) => { + let pretty_str = + serde_json::to_string_pretty(&json_value) + .unwrap_or_else(|_| { + "Error formatting JSON".to_string() + }); + + ui.add_space(2.0); + + // A resizable region that the user can drag to expand/shrink + egui::Resize::default() + .id_salt("json_resize_area_for_contract") + .default_size([400.0, 400.0]) // initial w,h + .show(ui, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.monospace(pretty_str); + }); + }); + + ui.add_space(3.0); + } + Err(e) => { + ui.label(format!( + "Error converting contract to JSON: {e}" + )); } } - } else if doc_resp.header_response.clicked() - && doc_resp.body_response.is_none() - { - // Doc type collapsed again: still have doc type & contract - // required, so do not clear them. Just clear index if any. - *selected_index = None; - *document_query = - format!("SELECT * FROM {}", selected_document_type.name()); - } - } - }); + }); + }); - // The Remove button - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - // Only show the remove button for non-DPNS contracts - if contract.alias != Some("dpns".to_string()) { - if ui.button("X").clicked() { - action |= AppAction::BackendTask(BackendTask::ContractTask( - ContractTask::RemoveContract( - contract.contract.id().clone(), - ), - )); + // Right‐aligned Remove button + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + // Only show the remove button for non-DPNS contracts + if contract.alias != Some("dpns".to_string()) { + if ui.button("X").clicked() { + action |= + AppAction::BackendTask(BackendTask::ContractTask( + ContractTask::RemoveContract( + contract.contract.id().clone(), + ), + )); + } } - } + }); }); - }); - } + } + }); }); }); diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 3453850bc..0db3c7019 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -52,6 +52,7 @@ pub fn add_left_panel( ), ("Q", RootScreenType::RootScreenDocumentQuery, "doc.png"), ("W", RootScreenType::RootScreenWalletsBalances, "wallet.png"), + ("O", RootScreenType::RootScreenMyTokenBalances, "tokens.png"), ( "T", RootScreenType::RootScreenToolsProofLogScreen, diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 5020aae62..92e361f49 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -2,6 +2,7 @@ pub mod contract_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; pub mod left_panel; +pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; pub mod top_panel; pub mod wallet_unlock; diff --git a/src/ui/components/tokens_subscreen_chooser_panel.rs b/src/ui/components/tokens_subscreen_chooser_panel.rs new file mode 100644 index 000000000..3f4479a43 --- /dev/null +++ b/src/ui/components/tokens_subscreen_chooser_panel.rs @@ -0,0 +1,76 @@ +use crate::context::AppContext; +use crate::ui::tokens::tokens_screen::TokensSubscreen; +use crate::ui::RootScreenType; +use crate::{app::AppAction, ui}; +use egui::{Color32, Context, Frame, Margin, RichText, SidePanel}; + +pub fn add_tokens_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { + let mut action = AppAction::None; + + let subscreens = vec![ + TokensSubscreen::MyTokens, + TokensSubscreen::SearchTokens, + TokensSubscreen::TokenCreator, + ]; + + let active_screen = match app_context.get_settings() { + Ok(Some(settings)) => match settings.1 { + ui::RootScreenType::RootScreenMyTokenBalances => TokensSubscreen::MyTokens, + ui::RootScreenType::RootScreenTokenSearch => TokensSubscreen::SearchTokens, + ui::RootScreenType::RootScreenTokenCreator => TokensSubscreen::TokenCreator, + _ => TokensSubscreen::MyTokens, + }, + _ => TokensSubscreen::MyTokens, // Fallback to Active screen if settings unavailable + }; + + SidePanel::left("tokens_subscreen_chooser_panel") + .default_width(250.0) + .frame( + Frame::none() + .fill(ctx.style().visuals.panel_fill) + .inner_margin(Margin::same(10.0)), + ) + .show(ctx, |ui| { + // Display subscreen names + ui.vertical(|ui| { + ui.label("Tokens Subscreens"); + ui.add_space(10.0); + + for subscreen in subscreens { + let is_active = active_screen == subscreen; + let (button_color, text_color) = if is_active { + (Color32::from_rgb(0, 128, 255), Color32::WHITE) + } else { + (Color32::GRAY, Color32::WHITE) + }; + let button = egui::Button::new( + RichText::new(subscreen.display_name()).color(text_color), + ) + .fill(button_color); + // Show the subscreen name as a clickable option + if ui.add(button).clicked() { + // Handle navigation based on which subscreen is selected + match subscreen { + TokensSubscreen::MyTokens => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenMyTokenBalances, + ) + } + TokensSubscreen::SearchTokens => { + action = + AppAction::SetMainScreen(RootScreenType::RootScreenTokenSearch) + } + TokensSubscreen::TokenCreator => { + action = + AppAction::SetMainScreen(RootScreenType::RootScreenTokenCreator) + } + } + } + + ui.add_space(5.0); + } + }); + }); + + action +} diff --git a/src/ui/contracts_documents/document_query_screen.rs b/src/ui/contracts_documents/document_query_screen.rs index 009f1dd18..a65066f3f 100644 --- a/src/ui/contracts_documents/document_query_screen.rs +++ b/src/ui/contracts_documents/document_query_screen.rs @@ -539,15 +539,19 @@ impl ScreenLike for DocumentQueryScreen { fn ui(&mut self, ctx: &Context) -> AppAction { self.check_error_expiration(); - let add_contract_button = ( - "Add Contracts", + let load_contract_button = ( + "Load Contracts", DesiredAppAction::AddScreenType(ScreenType::AddContracts), ); + let register_contract_button = ( + "Register Contract", + DesiredAppAction::AddScreenType(ScreenType::RegisterContract), + ); let mut action = add_top_panel( ctx, &self.app_context, vec![("Contracts", AppAction::None)], - vec![add_contract_button], + vec![load_contract_button, register_contract_button], ); action |= add_left_panel( diff --git a/src/ui/contracts_documents/mod.rs b/src/ui/contracts_documents/mod.rs index 7a803090b..447f2da9e 100644 --- a/src/ui/contracts_documents/mod.rs +++ b/src/ui/contracts_documents/mod.rs @@ -1,2 +1,3 @@ pub mod add_contracts_screen; pub mod document_query_screen; +pub mod register_contract_screen; diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs new file mode 100644 index 000000000..d07c31d94 --- /dev/null +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -0,0 +1,545 @@ +use crate::app::AppAction; +use crate::backend_task::contract::ContractTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Setters; +use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::{DataContract, IdentityPublicKey}; +use eframe::egui::{self, Color32, Context, TextEdit}; +use egui::{RichText, ScrollArea, Ui}; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(PartialEq)] +enum BroadcastStatus { + Idle, + ParsingError(String), + ValidContract(DataContract), + Broadcasting(u64), // store "start time" so we can show how long + BroadcastError(String), + Done, +} + +pub struct RegisterDataContractScreen { + pub app_context: Arc, + contract_json_input: String, + contract_alias_input: String, + broadcast_status: BroadcastStatus, + + pub show_identity_selector: bool, + pub show_key_selector: bool, + pub qualified_identities: Vec<(QualifiedIdentity, Vec)>, + pub selected_qualified_identity: Option<(QualifiedIdentity, Vec)>, + pub selected_key: Option, + + pub selected_wallet: Option>>, + wallet_password: String, + show_password: bool, + error_message: Option, +} + +impl RegisterDataContractScreen { + pub fn new(app_context: &Arc) -> Self { + let security_level_requirements = vec![SecurityLevel::HIGH, SecurityLevel::CRITICAL]; + + let qualified_identities: Vec<_> = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .filter_map(|e| { + let keys = e + .identity + .public_keys() + .values() + .filter(|key| { + key.purpose() == Purpose::AUTHENTICATION // is this right? + && security_level_requirements.contains(&key.security_level()) + && !key.is_disabled() + }) + .cloned() + .collect::>(); + if keys.is_empty() { + None + } else { + Some((e, keys)) + } + }) + .collect(); + let selected_qualified_identity = qualified_identities.first().cloned(); + + let mut error_message: Option = None; + let selected_wallet = if let Some(ref identity) = selected_qualified_identity { + get_selected_wallet(&identity.0, Some(app_context), None, &mut error_message) + } else { + None + }; + + let show_identity_selector = qualified_identities.len() > 1; + let show_key_selector = selected_qualified_identity.is_some(); + + Self { + app_context: app_context.clone(), + contract_json_input: String::new(), + contract_alias_input: String::new(), + broadcast_status: BroadcastStatus::Idle, + + show_identity_selector, + show_key_selector, + qualified_identities, + selected_qualified_identity, + selected_key: None, + + selected_wallet, + wallet_password: String::new(), + show_password: false, + error_message: None, + } + } + + fn render_identity_id_and_key_selection(&mut self, ui: &mut egui::Ui) { + if self.qualified_identities.len() == 1 { + // Only one identity, display it directly + let qualified_identity = &self.qualified_identities[0]; + ui.horizontal(|ui| { + ui.label("Identity ID:"); + ui.label( + qualified_identity + .0 + .alias + .as_ref() + .unwrap_or( + &qualified_identity + .0 + .identity + .id() + .to_string(Encoding::Base58), + ) + .clone(), + ); + }); + self.selected_qualified_identity = Some(qualified_identity.clone()); + } else { + // Multiple identities, display ComboBox + ui.horizontal(|ui| { + ui.label("Identity ID:"); + + // Create a ComboBox for selecting a Qualified Identity + egui::ComboBox::from_label("") + .selected_text( + self.selected_qualified_identity + .as_ref() + .map(|qi| { + qi.0.alias + .as_ref() + .unwrap_or(&qi.0.identity.id().to_string(Encoding::Base58)) + .clone() + }) + .unwrap_or_else(|| "Select an identity".to_string()), + ) + .show_ui(ui, |ui| { + // Loop through the qualified identities and display each as selectable + for qualified_identity in &self.qualified_identities { + // Display each QualifiedIdentity as a selectable item + if ui + .selectable_value( + &mut self.selected_qualified_identity, + Some(qualified_identity.clone()), + qualified_identity.0.alias.as_ref().unwrap_or( + &qualified_identity + .0 + .identity + .id() + .to_string(Encoding::Base58), + ), + ) + .clicked() + { + self.selected_qualified_identity = Some(qualified_identity.clone()); + self.selected_wallet = get_selected_wallet( + &qualified_identity.0, + Some(&self.app_context), + None, + &mut self.error_message, + ); + self.show_key_selector = true; + } + } + }); + }); + } + + // Key selection + if let Some(ref qid) = self.selected_qualified_identity { + // Attempt to list available keys (only auth keys in normal mode) + let keys = if self.app_context.developer_mode { + qid.0 + .identity + .public_keys() + .values() + .cloned() + .collect::>() + } else { + qid.0 + .available_authentication_keys() + .into_iter() + .filter_map(|k| { + if k.identity_public_key.security_level() == SecurityLevel::CRITICAL + || k.identity_public_key.security_level() == SecurityLevel::HIGH + { + Some(k.identity_public_key.clone()) + } else { + None + } + }) + .collect() + }; + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Key:"); + egui::ComboBox::from_id_salt("contract_creator_key_selector") + .selected_text(match &self.selected_key { + Some(k) => format!( + "Key {} (Purpose: {:?}, Security Level: {:?})", + k.id(), + k.purpose(), + k.security_level() + ), + None => "Select Key".to_owned(), + }) + .show_ui(ui, |ui| { + for k in keys { + let label = format!( + "Key {} (Purpose: {:?}, Security Level: {:?})", + k.id(), + k.purpose(), + k.security_level() + ); + if ui + .selectable_label( + Some(k.id()) == self.selected_key.as_ref().map(|kk| kk.id()), + label, + ) + .clicked() + { + self.selected_key = Some(k.clone()); + + // If the key belongs to a wallet, set that wallet reference: + self.selected_wallet = crate::ui::identities::get_selected_wallet( + &qid.0, + None, + Some(&k), + &mut self.error_message, + ); + } + } + }); + }); + } + } + + fn parse_contract(&mut self) { + // Clear any previous parse/broadcast states + self.broadcast_status = BroadcastStatus::Idle; + + if self.contract_json_input.trim().is_empty() { + // No input yet + return; + } + + // Try to parse the user’s JSON -> serde_json::Value + let json_result: Result = + serde_json::from_str(&self.contract_json_input); + + match json_result { + Ok(json_val) => { + let platform_version = PlatformVersion::latest(); + match DataContract::from_json(json_val, true, platform_version) { + Ok(mut contract) => { + // ------------------------------------------ + // 1) Overwrite the contract’s ownerId + // ------------------------------------------ + if let Some((qualified_identity, _keys)) = &self.selected_qualified_identity + { + let new_owner_id = qualified_identity.identity.id(); + contract.set_owner_id(new_owner_id); + } + + // Mark it as a valid contract in our screen state + self.broadcast_status = BroadcastStatus::ValidContract(contract); + } + Err(e) => { + self.broadcast_status = + BroadcastStatus::ParsingError(format!("DataContract parse error: {e}")); + } + } + } + Err(e) => { + self.broadcast_status = BroadcastStatus::ParsingError(format!("Invalid JSON: {e}")); + } + } + } + + fn ui_input_field(&mut self, ui: &mut egui::Ui) { + ScrollArea::vertical() + .max_height(ui.available_height() - 100.0) + .show(ui, |ui| { + let response = ui.add( + TextEdit::multiline(&mut self.contract_json_input) + .desired_rows(6) + .desired_width(ui.available_width()) + .code_editor(), + ); + if response.changed() { + self.parse_contract(); + } + }); + } + + fn ui_parsed_contract(&mut self, ui: &mut egui::Ui) -> AppAction { + let mut app_action = AppAction::None; + + ui.add_space(5.0); + + match &self.broadcast_status { + BroadcastStatus::Idle => { + ui.label("No contract parsed yet or empty input."); + } + BroadcastStatus::ParsingError(err) => { + ui.colored_label(Color32::RED, format!("Parsing error: {err}")); + } + BroadcastStatus::ValidContract(contract) => { + // “Register” button + ui.add_space(10.0); + // Register button + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + let button = + egui::Button::new(RichText::new("Register Contract").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .rounding(3.0); + if ui.add(button).clicked() { + // Fire off a backend task + app_action = AppAction::BackendTask(BackendTask::ContractTask( + ContractTask::RegisterDataContract( + contract.clone(), + self.contract_alias_input.clone(), + self.selected_qualified_identity.clone().unwrap().0, // unwrap should be safe here + self.selected_key.clone().unwrap(), // unwrap should be safe here + ), + )); + } + } + BroadcastStatus::Broadcasting(start_time) => { + // Show how long we've been broadcasting + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let elapsed = now - start_time; + ui.label(format!( + "Broadcasting contract... {} seconds elapsed.", + elapsed + )); + } + BroadcastStatus::BroadcastError(msg) => { + ui.colored_label(Color32::RED, format!("Broadcast error: {msg}")); + } + BroadcastStatus::Done => { + ui.colored_label(Color32::GREEN, "Data Contract registered successfully!"); + } + } + + match app_action { + AppAction::BackendTask(BackendTask::ContractTask( + ContractTask::RegisterDataContract(_, _, _, _), + )) => { + self.broadcast_status = BroadcastStatus::Broadcasting( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + } + _ => {} + } + + app_action + } + + pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Center the content vertically and horizontally + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Successfully registered data contract."); + + ui.add_space(20.0); + + if ui.button("Back to Contracts screen").clicked() { + action = AppAction::GoToMainScreen; + } + ui.add_space(5.0); + + if ui.button("Register another contract").clicked() { + self.contract_json_input = String::new(); + self.contract_alias_input = String::new(); + self.broadcast_status = BroadcastStatus::Idle; + } + }); + + action + } +} + +impl ScreenLike for RegisterDataContractScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + self.broadcast_status = BroadcastStatus::Done; + } + MessageType::Error => { + self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); + } + MessageType::Info => { + // You could display an info label, or do nothing + } + } + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + // If a separate result needs to be handled here, you can do so + // For example, if success is a special message or we want to show it in the UI + match result { + BackendTaskSuccessResult::Message(_msg) => { + self.broadcast_status = BroadcastStatus::Done; + } + _ => {} + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Contracts", AppAction::GoToMainScreen), + ("Register Data Contract", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.broadcast_status == BroadcastStatus::Done { + action |= self.show_success(ui); + return; + } + + ui.heading("Register Data Contract"); + ui.add_space(10.0); + + // If no identities loaded, give message + if self.qualified_identities.is_empty() { + ui.colored_label( + egui::Color32::DARK_RED, + "No qualified identities available to register a data contract.", + ); + return; + } + + // Select the identity to register the name for + ui.heading("1. Select Identity"); + ui.add_space(5.0); + self.render_identity_id_and_key_selection(ui); + ui.add_space(5.0); + if let Some(identity) = &self.selected_qualified_identity { + ui.label(format!( + "Identity balance: {:.6}", + identity.0.identity.balance() as f64 * 1e-11 + )); + } + + if self.selected_key.is_none() { + action = AppAction::None; + return; + } + + // Input for the alias + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + ui.heading("2. Contract alias for DET (optional)"); + ui.add_space(5.0); + ui.text_edit_singleline(&mut self.contract_alias_input); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + // Input for the contract + ui.heading("3. Paste the contract JSON below"); + ui.add_space(5.0); + self.ui_input_field(ui); + + // Parse the contract and show the result + action |= self.ui_parsed_contract(ui); + }); + + action + } +} + +// If you also need wallet unlocking, implement the trait +impl ScreenWithWalletUnlock for RegisterDataContractScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 3c1b244f4..ac94124b9 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -120,6 +120,9 @@ pub struct DPNSScreen { /// Sorting sort_column: SortColumn, sort_order: SortOrder, + active_filter_term: String, + past_filter_term: String, + owned_filter_term: String, /// Which sub-screen is active: Active contests, Past, Owned, or Scheduled pub dpns_subscreen: DPNSSubscreen, @@ -130,6 +133,7 @@ pub struct DPNSScreen { bulk_identity_options: Vec, bulk_schedule_message: Option<(MessageType, String)>, bulk_vote_handling_status: VoteHandlingStatus, + set_all_option: VoteOption, } impl DPNSScreen { @@ -187,6 +191,9 @@ impl DPNSScreen { message: None, sort_column: SortColumn::ContestedName, sort_order: SortOrder::Ascending, + active_filter_term: String::new(), + past_filter_term: String::new(), + owned_filter_term: String::new(), scheduled_vote_cast_in_progress: false, pending_backend_task: None, dpns_subscreen, @@ -197,6 +204,7 @@ impl DPNSScreen { bulk_identity_options, bulk_schedule_message: None, bulk_vote_handling_status: VoteHandlingStatus::NotStarted, + set_all_option: VoteOption::CastNow, } } @@ -337,9 +345,31 @@ impl DPNSScreen { /// Show the Active Contests table fn render_table_active_contests(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Filter by name:"); + ui.text_edit_singleline(&mut self.active_filter_term); + }); + let contested_names = { let guard = self.contested_names.lock().unwrap(); let mut cn = guard.clone(); + if !self.active_filter_term.is_empty() { + let mut filter_lc = self.active_filter_term.to_lowercase(); + // Convert o and O to 0 and l to 1 in filter_lc + filter_lc = filter_lc + .chars() + .map(|c| match c { + 'o' | 'O' => '0', + 'l' => '1', + _ => c, + }) + .collect(); + cn.retain(|c| { + c.normalized_contested_name + .to_lowercase() + .contains(&filter_lc) + }); + } self.sort_contested_names(&mut cn); cn }; @@ -665,10 +695,34 @@ impl DPNSScreen { /// Show a Past Contests table fn render_table_past_contests(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Filter by name:"); + ui.text_edit_singleline(&mut self.past_filter_term); + }); + let contested_names = { let guard = self.contested_names.lock().unwrap(); let mut cn = guard.clone(); cn.retain(|c| c.awarded_to.is_some() || c.state == ContestState::Locked); + // 1) Filter by `past_filter_term` + if !self.past_filter_term.is_empty() { + let mut filter_lc = self.past_filter_term.to_lowercase(); + // Convert o and O to 0 and l to 1 in filter_lc + filter_lc = filter_lc + .chars() + .map(|c| match c { + 'o' | 'O' => '0', + 'l' => '1', + _ => c, + }) + .collect(); + + cn.retain(|c| { + c.normalized_contested_name + .to_lowercase() + .contains(&filter_lc) + }); + } self.sort_contested_names(&mut cn); cn }; @@ -782,7 +836,13 @@ impl DPNSScreen { ui.label("Active"); } ContestState::WonBy(identifier) => { - ui.label(identifier.to_string(Encoding::Base58)); + ui.add( + egui::Label::new( + identifier.to_string(Encoding::Base58), + ) + .sense(egui::Sense::hover()) + .truncate(), + ); } ContestState::Locked => { ui.label("Locked"); @@ -797,12 +857,22 @@ impl DPNSScreen { /// Show the Owned DPNS names table fn render_table_local_dpns_names(&mut self, ui: &mut Ui) { - let mut sorted_names = { + ui.horizontal(|ui| { + ui.label("Filter by name:"); + ui.text_edit_singleline(&mut self.owned_filter_term); + }); + + let mut filtered_names = { let guard = self.local_dpns_names.lock().unwrap(); - guard.clone() + let mut name_infos = guard.clone(); + if !self.owned_filter_term.is_empty() { + let filter_lc = self.owned_filter_term.to_lowercase(); + name_infos.retain(|c| c.1.name.to_lowercase().contains(&filter_lc)); + } + name_infos }; // Sort - sorted_names.sort_by(|a, b| match self.sort_column { + filtered_names.sort_by(|a, b| match self.sort_column { SortColumn::ContestedName => { let order = a.1.name.cmp(&b.1.name); if self.sort_order == SortOrder::Descending { @@ -880,7 +950,7 @@ impl DPNSScreen { }); }) .body(|mut body| { - for (identifier, dpns_info) in sorted_names { + for (identifier, dpns_info) in filtered_names { body.row(25.0, |mut row| { row.col(|ui| { ui.label(dpns_info.name); @@ -1208,9 +1278,6 @@ impl DPNSScreen { return action; } - ui.colored_label(Color32::DARK_RED, "NOTE: Dash Evo Tool must remain running and connected for scheduled votes to execute on time."); - ui.add_space(10.0); - egui::ScrollArea::vertical().show(ui, |ui| { // Define a frame with custom background color and border Frame::group(ui.style()) @@ -1256,6 +1323,87 @@ impl DPNSScreen { // Show each identity + let user pick None / Immediate / Scheduled ui.heading("Select cast method for each node:"); + ui.add_space(10.0); + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label("Set all:"); + + // A ComboBox to pick No Vote / Cast Now / Schedule + ComboBox::from_id_salt("set_all_combo") + .width(120.0) + .selected_text(match self.set_all_option { + VoteOption::NoVote => "No Vote".to_string(), + VoteOption::CastNow => "Cast Now".to_string(), + VoteOption::Scheduled { .. } => "Schedule".to_string(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_label( + matches!(self.set_all_option, VoteOption::NoVote), + "No Vote", + ) + .clicked() + { + self.set_all_option = VoteOption::NoVote; + } + if ui + .selectable_label( + matches!(self.set_all_option, VoteOption::CastNow), + "Cast Now", + ) + .clicked() + { + self.set_all_option = VoteOption::CastNow; + } + if ui + .selectable_label( + matches!( + self.set_all_option, + VoteOption::Scheduled { .. } + ), + "Schedule", + ) + .clicked() + { + // Default scheduled values if none set yet + let (d, h, m) = match &self.set_all_option { + VoteOption::Scheduled { + days, + hours, + minutes, + } => (*days, *hours, *minutes), + _ => (0, 0, 0), + }; + self.set_all_option = VoteOption::Scheduled { + days: d, + hours: h, + minutes: m, + }; + } + }); + + // If scheduling, show the days/hours/minutes widgets inline + if let VoteOption::Scheduled { + ref mut days, + ref mut hours, + ref mut minutes, + } = self.set_all_option + { + ui.label("Schedule In:"); + ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); + ui.add(egui::DragValue::new(hours).prefix("Hours: ").range(0..=23)); + ui.add(egui::DragValue::new(minutes).prefix("Min: ").range(0..=59)); + } + + // Button to apply the "Set all" choice to each identity in bulk_identity_options + if ui.button("Apply to All").clicked() { + for option in &mut self.bulk_identity_options { + *option = self.set_all_option.clone(); + } + } + }); + }); + ui.add_space(10.0); for (i, identity) in self.voting_identities.iter().enumerate() { ui.group(|ui| { ui.horizontal(|ui| { @@ -1356,6 +1504,16 @@ impl DPNSScreen { }) }); + // If any selected votes are scheduled, show a warning + if self + .bulk_identity_options + .iter() + .any(|o| matches!(o, VoteOption::Scheduled { .. })) + { + ui.colored_label(Color32::DARK_RED, "NOTE: Dash Evo Tool must remain running and connected for scheduled votes to execute on time."); + ui.add_space(10.0); + } + // "Apply Votes" button let button = egui::Button::new(RichText::new("Apply Votes").color(Color32::WHITE)) .fill(Color32::from_rgb(0, 128, 255)) @@ -1492,12 +1650,33 @@ impl DPNSScreen { ui.add_space(20.0); match &self.bulk_vote_handling_status { VoteHandlingStatus::Completed => { - ui.heading("🎉"); - ui.heading("Successfully cast and scheduled all votes"); + // This means DET side was successful, but Platform may have returned errors + if let Some(message) = &self.bulk_schedule_message { + match message.0 { + MessageType::Error => { + ui.heading("❌"); + if message.1.contains("Successes") { + ui.heading("Only some votes succeeded"); + } else { + ui.heading("No votes succeeded"); + } + ui.add_space(10.0); + ui.label(message.1.clone()); + } + MessageType::Success => { + ui.heading("🎉"); + ui.heading("Successfully casted and scheduled all votes"); + } + _ => {} + } + } } VoteHandlingStatus::Failed(message) => { + // This means there was a DET-side error, not Platform-side ui.heading("❌"); - ui.heading(format!("Error casting and scheduling votes: {}", message)); + ui.heading("Error casting and scheduling votes (DET-side)"); + ui.add_space(10.0); + ui.label(message); } _ => { // this should not occur @@ -1505,14 +1684,6 @@ impl DPNSScreen { } ui.add_space(20.0); - if ui.button("Go to Scheduled Votes Screen").clicked() { - self.show_bulk_schedule_popup = false; - self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; - action = AppAction::SetMainScreenThenPopScreen( - RootScreenType::RootScreenDPNSScheduledVotes, - ); - } - ui.add_space(5.0); if ui.button("Go back to Active Contests").clicked() { self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; self.show_bulk_schedule_popup = false; @@ -1520,6 +1691,14 @@ impl DPNSScreen { ContestedResourceTask::QueryDPNSContests, )) } + ui.add_space(5.0); + if ui.button("Go to Scheduled Votes Screen").clicked() { + self.show_bulk_schedule_popup = false; + self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; + action = AppAction::SetMainScreenThenPopScreen( + RootScreenType::RootScreenDPNSScheduledVotes, + ); + } }); action @@ -1631,47 +1810,39 @@ impl ScreenLike for DPNSScreen { match backend_task_success_result { // If immediate cast finished, see if we have pending to schedule next BackendTaskSuccessResult::DPNSVoteResults(results) => { - let errors = results + let errors: Vec = results .iter() .filter_map(|(_, _, r)| r.as_ref().err().cloned()) - .collect::>(); - let successes = results + .collect(); + let successes: Vec = results .iter() - .filter_map(|(_, _, r)| r.as_ref().ok().cloned()) - .collect::>(); - // If there are errors + .filter_map(|(name, _, r)| r.as_ref().ok().map(|_| name.clone())) + .collect(); + if !errors.is_empty() { - // And successes + let errors_string = errors.join("\n\n"); if !successes.is_empty() { - self.bulk_schedule_message = Some(( - MessageType::Error, - format!("Only some votes succeeded. Errors: {:?}", errors), - )); - } else { - // All errors. We'll just display the first + // partial success self.bulk_schedule_message = Some(( MessageType::Error, format!( - "Error casting votes. No votes succeeded. The first error returned was: {:?}", - errors.first() + "Successes: {}/{}\n\nErrors:\n\n{:?}", + successes.len(), + successes.len() + errors.len(), + errors_string ), )); - } - } else { - // There were no errors. - // If there were successful votes... - if !successes.is_empty() { - self.bulk_schedule_message = Some(( - MessageType::Success, - format!("Votes all cast successfully."), - )); } else { - // No errors no successes - self.bulk_schedule_message = Some(( - MessageType::Error, - "No votes cast. Something went wrong.".to_string(), - )); + // all failed + self.bulk_schedule_message = + Some((MessageType::Error, format!("Errors:\n\n{}", errors_string))); } + } else { + // no errors => all success + self.bulk_schedule_message = Some(( + MessageType::Success, + "Votes all cast successfully.".to_string(), + )); } self.bulk_vote_handling_status = VoteHandlingStatus::Completed; diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 5f677baf7..02c5ad6d9 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -189,7 +189,7 @@ impl AddExistingIdentityScreen { .on_hover_text("Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform."); if response.clicked() { self.show_pop_up_info = Some("Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform.".to_string()); - } + } }); ui.text_edit_singleline(&mut self.alias_input); ui.label(""); @@ -224,14 +224,14 @@ impl AddExistingIdentityScreen { // First column: the label & info icon, combined horizontally ui.horizontal(|ui| { ui.label(format!("Private Key {} (Hex or WIF):", i + 1)); - + let info_icon = egui::Label::new("ℹ").sense(egui::Sense::click()); let response = ui .add(info_icon) .on_hover_text("You don't need to add all or even any private keys here. \ Private keys can be added later. However, without private keys, \ you won't be able to sign any transactions."); - + if response.clicked() { self.show_pop_up_info = Some( "You don't need to add all or even any private keys here. \ @@ -244,15 +244,15 @@ impl AddExistingIdentityScreen { // Second column: the text field ui.text_edit_singleline(key); - + // Third column: the remove button if ui.button("-").clicked() { keys_to_remove.push(i); } - + ui.end_row(); } - + // Remove the keys after the loop to avoid borrowing conflicts for i in keys_to_remove.iter().rev() { self.keys_input.remove(*i); diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index bb0f77690..b60e3ad32 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -31,6 +31,8 @@ impl AddNewIdentityScreen { if !has_address { self.app_context .core_client + .read() + .expect("Core client lock was poisoned") .import_address( &receive_address, Some("Managed by Dash Evo Tool"), @@ -43,12 +45,16 @@ impl AddNewIdentityScreen { let info = self .app_context .core_client + .read() + .expect("Core client lock was poisoned") .get_address_info(&receive_address) .map_err(|e| e.to_string())?; if !(info.is_watchonly || info.is_mine) { self.app_context .core_client + .read() + .expect("Core client lock was poisoned") .import_address( &receive_address, Some("Managed by Dash Evo Tool"), diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index ff7e005c9..7140973fd 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -30,7 +30,7 @@ use eframe::egui::{self, Context}; use eframe::emath::Align; use egui::{Color32, Frame, Margin, RichText, Ui}; use egui_extras::{Column, TableBuilder}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; @@ -101,12 +101,34 @@ impl IdentitiesScreen { screen } - /// Reorder the underlying IndexMap to match a list of IDs + /// Reorders `self.identities` to match the order of the provided list of IDs. + /// Any IDs not present in the provided list are left in their current position. fn reorder_map_to(&self, new_order: Vec) { let mut lock = self.identities.lock().unwrap(); - for (desired_idx, id) in new_order.iter().enumerate() { - if let Some(current_idx) = lock.get_index_of(id) { - if current_idx != desired_idx && current_idx < lock.len() { + if lock.is_empty() || new_order.is_empty() { + return; + } + + // 1) Collect the set of IDs currently in `self.identities` + let existing_ids: HashSet = lock.keys().cloned().collect(); + + // 2) Build a filtered list that only includes IDs which exist in the map + // (and also limit the length so we don’t swap out of range) + let valid_ids: Vec = new_order + .into_iter() + .filter(|id| existing_ids.contains(id)) + .take(lock.len()) // never try to reorder more items than we actually have + .collect(); + + // 3) Do the swaps only for items still present in our map, + // skipping any that no longer exist or where desired_idx is out of range + for (desired_idx, id) in valid_ids.into_iter().enumerate() { + // Double‐check the desired index is in range + if desired_idx >= lock.len() { + break; + } + if let Some(current_idx) = lock.get_index_of(&id) { + if current_idx != desired_idx { lock.swap_indices(current_idx, desired_idx); } } diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index ab460abda..859092e48 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -44,6 +44,7 @@ pub struct KeyInfoScreen { wallet_open: bool, view_private_key_even_if_encrypted_or_in_wallet: bool, show_pop_up_info: Option, + show_confirm_remove_private_key: bool, } // /// The prefix for signed messages using Dash's message signing protocol. @@ -217,6 +218,10 @@ impl ScreenLike for KeyInfoScreen { TextEdit::singleline(&mut private_key_hex.as_str().to_owned()) .desired_width(f32::INFINITY), ); + ui.add_space(10.0); + if ui.button("Remove private key from DET").clicked() { + self.show_confirm_remove_private_key = true; + } self.render_sign_input(ui); } PrivateKeyData::Encrypted(_) => { @@ -343,6 +348,12 @@ impl ScreenLike for KeyInfoScreen { } }); } + + // Show the remove private key confirmation popup + if self.show_confirm_remove_private_key { + self.render_remove_private_key_confirm(ui); + } + ui.add_space(10.0); }); }); @@ -384,6 +395,7 @@ impl KeyInfoScreen { wallet_open: false, view_private_key_even_if_encrypted_or_in_wallet: false, show_pop_up_info: None, + show_confirm_remove_private_key: false, } } @@ -534,6 +546,42 @@ impl KeyInfoScreen { self.sign_error_message = Some("Private key is not available.".to_string()); } } + + fn render_remove_private_key_confirm(&mut self, ui: &mut egui::Ui) { + egui::Window::new("Remove Private Key") + .collapsible(false) // Prevent collapsing + .resizable(false) // Prevent resizing + .show(ui.ctx(), |ui| { + ui.label("Are you sure you want to remove the private key?"); + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.show_confirm_remove_private_key = false; + } + ui.add_space(3.0); + if ui.button("Remove").clicked() { + self.private_key_data = None; + self.identity + .private_keys + .private_keys + .remove(&(self.key.purpose().into(), self.key.id())); + match self + .app_context + .insert_local_qualified_identity(&self.identity, None) + { + Ok(_) => { + self.error_message = None; + } + Err(e) => { + self.error_message = Some(format!("Issue saving: {}", e)); + } + } + self.show_confirm_remove_private_key = false; + } + }); + }); + } } impl ScreenWithWalletUnlock for KeyInfoScreen { diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 015c5d6fd..ea019dd97 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -84,7 +84,7 @@ impl RegisterDpnsNameScreen { None }; - let show_identity_selector = qualified_identities.len() > 0; + let show_identity_selector = qualified_identities.len() > 1; Self { show_identity_selector, qualified_identities, @@ -310,11 +310,11 @@ impl ScreenLike for RegisterDpnsNameScreen { ui.label("Name (without \".dash\"):"); ui.text_edit_singleline(&mut self.name_input); }); - ui.add_space(10.0); // Display if the name is contested and the estimated cost let name = self.name_input.trim(); if !name.is_empty() && name.len() >= 3 { + ui.add_space(10.0); if is_contested_name(&name.to_lowercase()) { ui.colored_label( egui::Color32::DARK_RED, @@ -356,6 +356,8 @@ impl ScreenLike for RegisterDpnsNameScreen { action = self.register_dpns_name_clicked(); } + ui.add_space(10.0); + // Handle registration status messages match &self.register_dpns_name_status { RegisterDpnsNameStatus::NotStarted => { diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 62d0c1ea4..20b46ccc0 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -27,6 +27,8 @@ impl TopUpIdentityScreen { if !has_address { self.app_context .core_client + .read() + .expect("Core client lock was poisoned") .import_address( &receive_address, Some("Managed by Dash Evo Tool"), @@ -39,12 +41,16 @@ impl TopUpIdentityScreen { let info = self .app_context .core_client + .read() + .expect("Core client lock was poisoned") .get_address_info(&receive_address) .map_err(|e| e.to_string())?; if !(info.is_watchonly || info.is_mine) { self.app_context .core_client + .read() + .expect("Core client lock was poisoned") .import_address( &receive_address, Some("Managed by Dash Evo Tool"), diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 9a0a86687..4b0220afe 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -344,8 +344,19 @@ impl ScreenLike for TransferScreen { // Select the key to sign with ui.heading("1. Select the key to sign the transaction with"); - ui.add_space(5.0); - self.render_key_selection(ui); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 02fcbacbc..30a1d20fc 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -13,6 +13,7 @@ use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context, Ui}; @@ -384,8 +385,19 @@ impl ScreenLike for WithdrawalScreen { } else { // Select the key to sign with ui.heading("1. Select the key to sign with"); - ui.add_space(5.0); - self.render_key_selection(ui); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); // Render wallet unlock component if needed if let Some(selected_key) = self.selected_key.as_ref() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c4fd1b53e..ba3ecb16d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,11 +14,13 @@ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::identities::withdraw_screen::WithdrawalScreen; use crate::ui::network_chooser_screen::NetworkChooserScreen; +use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::wallets::import_wallet_screen::ImportWalletScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use contracts_documents::add_contracts_screen::AddContractsScreen; +use contracts_documents::register_contract_screen::RegisterDataContractScreen; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; use dpns::dpns_contested_names_screen::DPNSSubscreen; @@ -30,6 +32,14 @@ use identities::register_dpns_name_screen::RegisterDpnsNameScreen; use std::fmt; use std::hash::Hash; use std::sync::Arc; +use tokens::burn_tokens_screen::BurnTokensScreen; +use tokens::destroy_frozen_funds_screen::DestroyFrozenFundsScreen; +use tokens::freeze_tokens_screen::FreezeTokensScreen; +use tokens::mint_tokens_screen::MintTokensScreen; +use tokens::pause_tokens_screen::PauseTokensScreen; +use tokens::resume_tokens_screen::ResumeTokensScreen; +use tokens::tokens_screen::{IdentityTokenBalance, TokensScreen, TokensSubscreen}; +use tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; @@ -38,6 +48,7 @@ pub mod contracts_documents; pub mod dpns; pub(crate) mod identities; pub mod network_chooser_screen; +pub mod tokens; pub mod tools; pub(crate) mod wallets; @@ -54,6 +65,9 @@ pub enum RootScreenType { RootScreenToolsTransitionVisualizerScreen, RootScreenNetworkChooser, RootScreenToolsProofVisualizerScreen, + RootScreenMyTokenBalances, + RootScreenTokenSearch, + RootScreenTokenCreator, } impl RootScreenType { @@ -68,9 +82,13 @@ impl RootScreenType { RootScreenType::RootScreenWalletsBalances => 5, RootScreenType::RootScreenToolsTransitionVisualizerScreen => 6, RootScreenType::RootScreenNetworkChooser => 7, + // 8 used to be the Withdrawals Statuses screen RootScreenType::RootScreenToolsProofLogScreen => 9, RootScreenType::RootScreenDPNSScheduledVotes => 10, RootScreenType::RootScreenToolsProofVisualizerScreen => 11, + RootScreenType::RootScreenMyTokenBalances => 12, + RootScreenType::RootScreenTokenSearch => 13, + RootScreenType::RootScreenTokenCreator => 14, } } @@ -85,9 +103,13 @@ impl RootScreenType { 5 => Some(RootScreenType::RootScreenWalletsBalances), 6 => Some(RootScreenType::RootScreenToolsTransitionVisualizerScreen), 7 => Some(RootScreenType::RootScreenNetworkChooser), + // 8 used to be the Withdrawals Statuses screen 9 => Some(RootScreenType::RootScreenToolsProofLogScreen), 10 => Some(RootScreenType::RootScreenDPNSScheduledVotes), 11 => Some(RootScreenType::RootScreenToolsProofVisualizerScreen), + 12 => Some(RootScreenType::RootScreenMyTokenBalances), + 13 => Some(RootScreenType::RootScreenTokenSearch), + 14 => Some(RootScreenType::RootScreenTokenCreator), _ => None, } } @@ -103,12 +125,15 @@ impl From for ScreenType { RootScreenType::RootScreenToolsTransitionVisualizerScreen => { ScreenType::TransitionVisualizer } - RootScreenType::RootScreenDocumentQuery => ScreenType::DocumentQueryScreen, + RootScreenType::RootScreenDocumentQuery => ScreenType::DocumentQuery, RootScreenType::RootScreenNetworkChooser => ScreenType::NetworkChooser, RootScreenType::RootScreenWalletsBalances => ScreenType::WalletsBalances, RootScreenType::RootScreenToolsProofLogScreen => ScreenType::ProofLog, RootScreenType::RootScreenDPNSScheduledVotes => ScreenType::ScheduledVotes, RootScreenType::RootScreenToolsProofVisualizerScreen => ScreenType::ProofVisualizer, + RootScreenType::RootScreenMyTokenBalances => ScreenType::TokenBalances, + RootScreenType::RootScreenTokenSearch => ScreenType::TokenSearch, + RootScreenType::RootScreenTokenCreator => ScreenType::TokenCreator, } } } @@ -135,14 +160,28 @@ pub enum ScreenType { Option<(PrivateKeyData, Option)>, ), Keys(Identity), - DocumentQueryScreen, + DocumentQuery, NetworkChooser, RegisterDpnsName, + RegisterContract, ProofLog, TopUpIdentity(QualifiedIdentity), ScheduledVotes, AddContracts, ProofVisualizer, + + // Token Screens + TokenBalances, + TokenSearch, + TokenCreator, + TransferTokensScreen(IdentityTokenBalance), + MintTokensScreen(IdentityTokenBalance), + BurnTokensScreen(IdentityTokenBalance), + DestroyFrozenFundsScreen(IdentityTokenBalance), + FreezeTokensScreen(IdentityTokenBalance), + UnfreezeTokensScreen(IdentityTokenBalance), + PauseTokensScreen(IdentityTokenBalance), + ResumeTokensScreen(IdentityTokenBalance), } impl ScreenType { @@ -181,6 +220,9 @@ impl ScreenType { ScreenType::RegisterDpnsName => { Screen::RegisterDpnsNameScreen(RegisterDpnsNameScreen::new(app_context)) } + ScreenType::RegisterContract => { + Screen::RegisterDataContractScreen(RegisterDataContractScreen::new(app_context)) + } ScreenType::TransitionVisualizer => { Screen::TransitionVisualizerScreen(TransitionVisualizerScreen::new(app_context)) } @@ -196,7 +238,7 @@ impl ScreenType { ScreenType::AddKeyScreen(identity) => { Screen::AddKeyScreen(AddKeyScreen::new(identity.clone(), app_context)) } - ScreenType::DocumentQueryScreen => { + ScreenType::DocumentQuery => { Screen::DocumentQueryScreen(DocumentQueryScreen::new(app_context)) } ScreenType::AddNewWallet => { @@ -218,6 +260,61 @@ impl ScreenType { ScreenType::ProofVisualizer => { Screen::ProofVisualizerScreen(ProofVisualizerScreen::new(app_context)) } + + // Token Screens + ScreenType::TokenBalances => { + Screen::TokensScreen(TokensScreen::new(app_context, TokensSubscreen::MyTokens)) + } + ScreenType::TokenSearch => Screen::TokensScreen(TokensScreen::new( + app_context, + TokensSubscreen::SearchTokens, + )), + ScreenType::TokenCreator => Screen::TokensScreen(TokensScreen::new( + app_context, + TokensSubscreen::TokenCreator, + )), + ScreenType::TransferTokensScreen(identity_token_balance) => { + Screen::TransferTokensScreen(TransferTokensScreen::new( + identity_token_balance.clone(), + app_context, + )) + } + ScreenType::MintTokensScreen(identity_token_balance) => Screen::MintTokensScreen( + MintTokensScreen::new(identity_token_balance.clone(), app_context), + ), + ScreenType::BurnTokensScreen(identity_token_balance) => Screen::BurnTokensScreen( + BurnTokensScreen::new(identity_token_balance.clone(), app_context), + ), + ScreenType::DestroyFrozenFundsScreen(identity_token_balance) => { + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new( + identity_token_balance.clone(), + app_context, + )) + } + ScreenType::FreezeTokensScreen(identity_token_balance) => { + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new( + identity_token_balance.clone(), + app_context, + )) + } + ScreenType::UnfreezeTokensScreen(identity_token_balance) => { + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new( + identity_token_balance.clone(), + app_context, + )) + } + ScreenType::PauseTokensScreen(identity_token_balance) => { + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new( + identity_token_balance.clone(), + app_context, + )) + } + ScreenType::ResumeTokensScreen(identity_token_balance) => { + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new( + identity_token_balance.clone(), + app_context, + )) + } } } } @@ -233,6 +330,7 @@ pub enum Screen { KeyInfoScreen(KeyInfoScreen), KeysScreen(KeysScreen), RegisterDpnsNameScreen(RegisterDpnsNameScreen), + RegisterDataContractScreen(RegisterDataContractScreen), WithdrawalScreen(WithdrawalScreen), TopUpIdentityScreen(TopUpIdentityScreen), TransferScreen(TransferScreen), @@ -243,6 +341,17 @@ pub enum Screen { WalletsBalancesScreen(WalletsBalancesScreen), AddContractsScreen(AddContractsScreen), ProofVisualizerScreen(ProofVisualizerScreen), + + // Token Screens + TokensScreen(TokensScreen), + TransferTokensScreen(TransferTokensScreen), + MintTokensScreen(MintTokensScreen), + BurnTokensScreen(BurnTokensScreen), + DestroyFrozenFundsScreen(DestroyFrozenFundsScreen), + FreezeTokensScreen(FreezeTokensScreen), + UnfreezeTokensScreen(UnfreezeTokensScreen), + PauseTokensScreen(PauseTokensScreen), + ResumeTokensScreen(ResumeTokensScreen), } impl Screen { @@ -260,6 +369,7 @@ impl Screen { Screen::DocumentQueryScreen(screen) => screen.app_context = app_context, Screen::AddNewIdentityScreen(screen) => screen.app_context = app_context, Screen::RegisterDpnsNameScreen(screen) => screen.app_context = app_context, + Screen::RegisterDataContractScreen(screen) => screen.app_context = app_context, Screen::AddNewWalletScreen(screen) => screen.app_context = app_context, Screen::TransferScreen(screen) => screen.app_context = app_context, Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, @@ -268,6 +378,17 @@ impl Screen { Screen::ProofLogScreen(screen) => screen.app_context = app_context, Screen::AddContractsScreen(screen) => screen.app_context = app_context, Screen::ProofVisualizerScreen(screen) => screen.app_context = app_context, + + // Token Screens + Screen::TokensScreen(screen) => screen.app_context = app_context, + Screen::TransferTokensScreen(screen) => screen.app_context = app_context, + Screen::MintTokensScreen(screen) => screen.app_context = app_context, + Screen::BurnTokensScreen(screen) => screen.app_context = app_context, + Screen::DestroyFrozenFundsScreen(screen) => screen.app_context = app_context, + Screen::FreezeTokensScreen(screen) => screen.app_context = app_context, + Screen::UnfreezeTokensScreen(screen) => screen.app_context = app_context, + Screen::PauseTokensScreen(screen) => screen.app_context = app_context, + Screen::ResumeTokensScreen(screen) => screen.app_context = app_context, } } } @@ -340,19 +461,58 @@ impl Screen { } Screen::NetworkChooserScreen(_) => ScreenType::NetworkChooser, Screen::AddKeyScreen(screen) => ScreenType::AddKeyScreen(screen.identity.clone()), - Screen::DocumentQueryScreen(_) => ScreenType::DocumentQueryScreen, + Screen::DocumentQueryScreen(_) => ScreenType::DocumentQuery, Screen::AddNewIdentityScreen(_) => ScreenType::AddExistingIdentity, Screen::TopUpIdentityScreen(screen) => { ScreenType::TopUpIdentity(screen.identity.clone()) } Screen::RegisterDpnsNameScreen(_) => ScreenType::RegisterDpnsName, + Screen::RegisterDataContractScreen(_) => ScreenType::RegisterContract, Screen::AddNewWalletScreen(_) => ScreenType::AddNewWallet, - Screen::TransferScreen(screen) => ScreenType::TransferScreen(screen.identity.clone()), Screen::WalletsBalancesScreen(_) => ScreenType::WalletsBalances, Screen::ImportWalletScreen(_) => ScreenType::ImportWallet, Screen::ProofLogScreen(_) => ScreenType::ProofLog, Screen::AddContractsScreen(_) => ScreenType::AddContracts, Screen::ProofVisualizerScreen(_) => ScreenType::ProofVisualizer, + + // Token Screens + Screen::TokensScreen(TokensScreen { + tokens_subscreen: TokensSubscreen::MyTokens, + .. + }) => ScreenType::TokenBalances, + Screen::TokensScreen(TokensScreen { + tokens_subscreen: TokensSubscreen::SearchTokens, + .. + }) => ScreenType::TokenSearch, + Screen::TokensScreen(TokensScreen { + tokens_subscreen: TokensSubscreen::TokenCreator, + .. + }) => ScreenType::TokenCreator, + Screen::TransferScreen(screen) => ScreenType::TransferScreen(screen.identity.clone()), + Screen::TransferTokensScreen(screen) => { + ScreenType::TransferTokensScreen(screen.identity_token_balance.clone()) + } + Screen::MintTokensScreen(screen) => { + ScreenType::MintTokensScreen(screen.identity_token_balance.clone()) + } + Screen::BurnTokensScreen(screen) => { + ScreenType::BurnTokensScreen(screen.identity_token_balance.clone()) + } + Screen::DestroyFrozenFundsScreen(screen) => { + ScreenType::DestroyFrozenFundsScreen(screen.identity_token_balance.clone()) + } + Screen::FreezeTokensScreen(screen) => { + ScreenType::FreezeTokensScreen(screen.identity_token_balance.clone()) + } + Screen::UnfreezeTokensScreen(screen) => { + ScreenType::UnfreezeTokensScreen(screen.identity_token_balance.clone()) + } + Screen::PauseTokensScreen(screen) => { + ScreenType::PauseTokensScreen(screen.identity_token_balance.clone()) + } + Screen::ResumeTokensScreen(screen) => { + ScreenType::ResumeTokensScreen(screen.identity_token_balance.clone()) + } } } } @@ -371,6 +531,7 @@ impl ScreenLike for Screen { Screen::KeyInfoScreen(screen) => screen.refresh(), Screen::KeysScreen(screen) => screen.refresh(), Screen::RegisterDpnsNameScreen(screen) => screen.refresh(), + Screen::RegisterDataContractScreen(screen) => screen.refresh(), Screen::WithdrawalScreen(screen) => screen.refresh(), Screen::TransferScreen(screen) => screen.refresh(), Screen::AddKeyScreen(screen) => screen.refresh(), @@ -380,6 +541,17 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.refresh(), Screen::AddContractsScreen(screen) => screen.refresh(), Screen::ProofVisualizerScreen(screen) => screen.refresh(), + + // Token Screens + Screen::TokensScreen(screen) => screen.refresh(), + Screen::TransferTokensScreen(screen) => screen.refresh(), + Screen::MintTokensScreen(screen) => screen.refresh(), + Screen::BurnTokensScreen(screen) => screen.refresh(), + Screen::DestroyFrozenFundsScreen(screen) => screen.refresh(), + Screen::FreezeTokensScreen(screen) => screen.refresh(), + Screen::UnfreezeTokensScreen(screen) => screen.refresh(), + Screen::PauseTokensScreen(screen) => screen.refresh(), + Screen::ResumeTokensScreen(screen) => screen.refresh(), } } @@ -396,6 +568,7 @@ impl ScreenLike for Screen { Screen::KeyInfoScreen(screen) => screen.refresh_on_arrival(), Screen::KeysScreen(screen) => screen.refresh_on_arrival(), Screen::RegisterDpnsNameScreen(screen) => screen.refresh_on_arrival(), + Screen::RegisterDataContractScreen(screen) => screen.refresh_on_arrival(), Screen::WithdrawalScreen(screen) => screen.refresh_on_arrival(), Screen::TransferScreen(screen) => screen.refresh_on_arrival(), Screen::AddKeyScreen(screen) => screen.refresh_on_arrival(), @@ -405,6 +578,17 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.refresh_on_arrival(), Screen::AddContractsScreen(screen) => screen.refresh_on_arrival(), Screen::ProofVisualizerScreen(screen) => screen.refresh_on_arrival(), + + // Token Screens + Screen::TokensScreen(screen) => screen.refresh_on_arrival(), + Screen::TransferTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::MintTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::BurnTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::DestroyFrozenFundsScreen(screen) => screen.refresh_on_arrival(), + Screen::FreezeTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::UnfreezeTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::PauseTokensScreen(screen) => screen.refresh_on_arrival(), + Screen::ResumeTokensScreen(screen) => screen.refresh_on_arrival(), } } @@ -421,6 +605,7 @@ impl ScreenLike for Screen { Screen::KeyInfoScreen(screen) => screen.ui(ctx), Screen::KeysScreen(screen) => screen.ui(ctx), Screen::RegisterDpnsNameScreen(screen) => screen.ui(ctx), + Screen::RegisterDataContractScreen(screen) => screen.ui(ctx), Screen::WithdrawalScreen(screen) => screen.ui(ctx), Screen::TransferScreen(screen) => screen.ui(ctx), Screen::AddKeyScreen(screen) => screen.ui(ctx), @@ -430,6 +615,17 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.ui(ctx), Screen::AddContractsScreen(screen) => screen.ui(ctx), Screen::ProofVisualizerScreen(screen) => screen.ui(ctx), + + // Token Screens + Screen::TokensScreen(screen) => screen.ui(ctx), + Screen::TransferTokensScreen(screen) => screen.ui(ctx), + Screen::MintTokensScreen(screen) => screen.ui(ctx), + Screen::BurnTokensScreen(screen) => screen.ui(ctx), + Screen::DestroyFrozenFundsScreen(screen) => screen.ui(ctx), + Screen::FreezeTokensScreen(screen) => screen.ui(ctx), + Screen::UnfreezeTokensScreen(screen) => screen.ui(ctx), + Screen::PauseTokensScreen(screen) => screen.ui(ctx), + Screen::ResumeTokensScreen(screen) => screen.ui(ctx), } } @@ -448,6 +644,9 @@ impl ScreenLike for Screen { Screen::KeyInfoScreen(screen) => screen.display_message(message, message_type), Screen::KeysScreen(screen) => screen.display_message(message, message_type), Screen::RegisterDpnsNameScreen(screen) => screen.display_message(message, message_type), + Screen::RegisterDataContractScreen(screen) => { + screen.display_message(message, message_type) + } Screen::WithdrawalScreen(screen) => screen.display_message(message, message_type), Screen::TransferScreen(screen) => screen.display_message(message, message_type), Screen::AddKeyScreen(screen) => screen.display_message(message, message_type), @@ -459,6 +658,19 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.display_message(message, message_type), Screen::AddContractsScreen(screen) => screen.display_message(message, message_type), Screen::ProofVisualizerScreen(screen) => screen.display_message(message, message_type), + + // Token Screens + Screen::TokensScreen(screen) => screen.display_message(message, message_type), + Screen::TransferTokensScreen(screen) => screen.display_message(message, message_type), + Screen::MintTokensScreen(screen) => screen.display_message(message, message_type), + Screen::BurnTokensScreen(screen) => screen.display_message(message, message_type), + Screen::DestroyFrozenFundsScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::FreezeTokensScreen(screen) => screen.display_message(message, message_type), + Screen::UnfreezeTokensScreen(screen) => screen.display_message(message, message_type), + Screen::PauseTokensScreen(screen) => screen.display_message(message, message_type), + Screen::ResumeTokensScreen(screen) => screen.display_message(message, message_type), } } @@ -497,6 +709,9 @@ impl ScreenLike for Screen { Screen::RegisterDpnsNameScreen(screen) => { screen.display_task_result(backend_task_success_result.clone()) } + Screen::RegisterDataContractScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } Screen::WithdrawalScreen(screen) => { screen.display_task_result(backend_task_success_result.clone()) } @@ -524,6 +739,33 @@ impl ScreenLike for Screen { Screen::ProofVisualizerScreen(screen) => { screen.display_task_result(backend_task_success_result) } + + // Token Screens + Screen::TokensScreen(screen) => screen.display_task_result(backend_task_success_result), + Screen::TransferTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::MintTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::BurnTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::DestroyFrozenFundsScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::FreezeTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::UnfreezeTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::PauseTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } + Screen::ResumeTokensScreen(screen) => { + screen.display_task_result(backend_task_success_result.clone()) + } } } @@ -540,6 +782,7 @@ impl ScreenLike for Screen { Screen::KeyInfoScreen(screen) => screen.pop_on_success(), Screen::KeysScreen(screen) => screen.pop_on_success(), Screen::RegisterDpnsNameScreen(screen) => screen.pop_on_success(), + Screen::RegisterDataContractScreen(screen) => screen.pop_on_success(), Screen::WithdrawalScreen(screen) => screen.pop_on_success(), Screen::TransferScreen(screen) => screen.pop_on_success(), Screen::AddKeyScreen(screen) => screen.pop_on_success(), @@ -549,6 +792,17 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.pop_on_success(), Screen::AddContractsScreen(screen) => screen.pop_on_success(), Screen::ProofVisualizerScreen(screen) => screen.pop_on_success(), + + // Token Screens + Screen::TokensScreen(screen) => screen.pop_on_success(), + Screen::TransferTokensScreen(screen) => screen.pop_on_success(), + Screen::MintTokensScreen(screen) => screen.pop_on_success(), + Screen::BurnTokensScreen(screen) => screen.pop_on_success(), + Screen::DestroyFrozenFundsScreen(screen) => screen.pop_on_success(), + Screen::FreezeTokensScreen(screen) => screen.pop_on_success(), + Screen::UnfreezeTokensScreen(screen) => screen.pop_on_success(), + Screen::PauseTokensScreen(screen) => screen.pop_on_success(), + Screen::ResumeTokensScreen(screen) => screen.pop_on_success(), } } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 083ab68be..ae4d08db7 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,11 +1,11 @@ use crate::app::AppAction; use crate::backend_task::core::{CoreItem, CoreTask}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::config::Config; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::wallets::add_new_wallet_screen::AddNewWalletScreen; -use crate::ui::{RootScreenType, Screen, ScreenLike}; +use crate::ui::{RootScreenType, ScreenLike}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; use eframe::egui::{self, Color32, Context, Ui}; @@ -15,9 +15,14 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub struct NetworkChooserScreen { pub mainnet_app_context: Arc, pub testnet_app_context: Option>, + pub devnet_app_context: Option>, + pub local_app_context: Option>, + pub local_network_dashmate_password: String, pub current_network: Network, pub mainnet_core_status_online: bool, pub testnet_core_status_online: bool, + pub devnet_core_status_online: bool, + pub local_core_status_online: bool, pub recheck_time: Option, custom_dash_qt_path: Option, custom_dash_qt_error_message: Option, @@ -28,16 +33,33 @@ impl NetworkChooserScreen { pub fn new( mainnet_app_context: &Arc, testnet_app_context: Option<&Arc>, + devnet_app_context: Option<&Arc>, + local_app_context: Option<&Arc>, current_network: Network, custom_dash_qt_path: Option, overwrite_dash_conf: bool, ) -> Self { + let local_network_dashmate_password = if let Ok(config) = Config::load() { + if let Some(local_config) = config.config_for_network(Network::Regtest) { + local_config.core_rpc_password.clone() + } else { + "".to_string() + } + } else { + "".to_string() + }; + Self { mainnet_app_context: mainnet_app_context.clone(), testnet_app_context: testnet_app_context.cloned(), + devnet_app_context: devnet_app_context.cloned(), + local_app_context: local_app_context.cloned(), + local_network_dashmate_password, current_network, mainnet_core_status_online: false, testnet_core_status_online: false, + devnet_core_status_online: false, + local_core_status_online: false, recheck_time: None, custom_dash_qt_path, custom_dash_qt_error_message: None, @@ -51,6 +73,12 @@ impl NetworkChooserScreen { Network::Testnet if self.testnet_app_context.is_some() => { self.testnet_app_context.as_ref().unwrap() } + Network::Devnet if self.devnet_app_context.is_some() => { + self.devnet_app_context.as_ref().unwrap() + } + Network::Regtest if self.local_app_context.is_some() => { + self.local_app_context.as_ref().unwrap() + } _ => &self.mainnet_app_context, } } @@ -74,6 +102,11 @@ impl NetworkChooserScreen { // ui.label(egui::RichText::new("Add New Wallet").strong().underline()); ui.label(egui::RichText::new("Select").strong().underline()); ui.label(egui::RichText::new("Start").strong().underline()); + ui.label( + egui::RichText::new("Dashmate Password") + .strong() + .underline(), + ); ui.end_row(); // Render Mainnet Row @@ -81,6 +114,12 @@ impl NetworkChooserScreen { // Render Testnet Row app_action |= self.render_network_row(ui, Network::Testnet, "Testnet"); + + // Render Devnet Row + app_action |= self.render_network_row(ui, Network::Devnet, "Devnet"); + + // Render Local Row + app_action |= self.render_network_row(ui, Network::Regtest, "Local"); }); ui.add_space(10.0); @@ -91,7 +130,6 @@ impl NetworkChooserScreen { egui::Grid::new("advanced_settings") .show(ui, |ui| { ui.label("Custom Dash-QT path:"); - if ui.button("Select file").clicked() { if let Some(path) = rfd::FileDialog::new().pick_file() { { @@ -144,16 +182,24 @@ impl NetworkChooserScreen { ui.label("zmqpubrawtxlocksig=tcp://0.0.0.0:23708"); ui.end_row(); ui.label("zmqpubrawchainlock=tcp://0.0.0.0:23708"); - } - else { //Testnet + } else if self.current_network == Network::Testnet { ui.colored_label(egui::Color32::ORANGE, "The following lines must be included in the custom Testnet dash.conf:"); ui.end_row(); ui.label("zmqpubrawtxlocksig=tcp://0.0.0.0:23709"); ui.end_row(); ui.label("zmqpubrawchainlock=tcp://0.0.0.0:23709"); + } else if self.current_network == Network::Devnet { + ui.colored_label(egui::Color32::ORANGE, "The following lines must be included in the custom Devnet dash.conf:"); + ui.end_row(); + ui.label("zmqpubrawtxlocksig=tcp://0.0.0.0:23710"); + ui.end_row(); + ui.label("zmqpubrawchainlock=tcp://0.0.0.0:23710"); + } else if self.current_network == Network::Regtest { + ui.colored_label(egui::Color32::ORANGE, "The following lines must be included in the custom Regtest dash.conf:"); + ui.end_row(); + ui.label("zmqpubrawtxlocksig=tcp://0.0.0.0:20302"); } } - }); }); app_action @@ -177,30 +223,19 @@ impl NetworkChooserScreen { if network == Network::Testnet && self.testnet_app_context.is_none() { ui.label("(No configs for testnet loaded)"); + ui.end_row(); + return AppAction::None; + } + if network == Network::Devnet && self.devnet_app_context.is_none() { + ui.label("(No configs for devnet loaded)"); + ui.end_row(); + return AppAction::None; + } + if network == Network::Regtest && self.local_app_context.is_none() { + ui.label("(No configs for local loaded)"); + ui.end_row(); return AppAction::None; } - - // // Display wallet count - // let wallet_count = format!( - // "{}", - // self.context_for_network(network) - // .wallets - // .read() - // .unwrap() - // .len() - // ); - // ui.label(wallet_count); - - // // Add a button to add a wallet - // if ui.button("+").clicked() { - // let context = if network == Network::Dash || self.testnet_app_context.is_none() { - // &self.mainnet_app_context - // } else { - // &self.testnet_app_context.as_ref().unwrap() - // }; - // app_action = - // AppAction::AddScreen(Screen::AddNewWalletScreen(AddNewWalletScreen::new(context))); - // } // Network selection let mut is_selected = self.current_network == network; @@ -218,14 +253,66 @@ impl NetworkChooserScreen { } // Add a button to start the network - if ui.button("Start").clicked() { - app_action = AppAction::BackendTask(BackendTask::CoreTask(CoreTask::StartDashQT( - network, - self.custom_dash_qt_path.clone(), - self.overwrite_dash_conf, - ))); + if !(network == Network::Regtest) { + if ui.button("Start").clicked() { + app_action = AppAction::BackendTask(BackendTask::CoreTask(CoreTask::StartDashQT( + network, + self.custom_dash_qt_path.clone(), + self.overwrite_dash_conf, + ))); + } + } else { + ui.label(" -"); } + // Add a text field for the dashmate password + if network == Network::Regtest { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.add( + egui::TextEdit::singleline(&mut self.local_network_dashmate_password) + .desired_width(100.0), + ); + if ui.button("Save").clicked() { + // 1) Reload the config + if let Ok(mut config) = Config::load() { + if let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() + { + let updated_local_config = local_cfg.update_core_rpc_password( + self.local_network_dashmate_password.clone(), + ); + config.update_config_for_network( + Network::Regtest, + updated_local_config.clone(), + ); + if let Err(e) = config.save() { + eprintln!("Failed to save config to .env: {e}"); + } + + // 5) Update our local AppContext in memory + if let Some(local_app_context) = &self.local_app_context { + { + // Overwrite the config field with the new password + let mut cfg_lock = local_app_context.config.write().unwrap(); + *cfg_lock = updated_local_config; + } + + // 6) Re-init the client & sdk from the updated config + if let Err(e) = + Arc::clone(local_app_context).reinit_core_client_and_sdk() + { + eprintln!("Failed to re-init local RPC client and sdk: {}", e); + } else { + // Trigger SwitchNetworks + app_action = AppAction::SwitchNetwork(Network::Regtest); + } + } + } + } + } + }); + }; + ui.end_row(); app_action } @@ -235,6 +322,8 @@ impl NetworkChooserScreen { match network { Network::Dash => self.mainnet_core_status_online, Network::Testnet => self.testnet_core_status_online, + Network::Devnet => self.devnet_core_status_online, + Network::Regtest => self.local_core_status_online, _ => false, } } @@ -242,9 +331,12 @@ impl NetworkChooserScreen { impl ScreenLike for NetworkChooserScreen { fn display_message(&mut self, message: &str, _message_type: super::MessageType) { - if message.contains("Failed to get best chain lock for both mainnet and testnet") { + if message.contains("Failed to get best chain lock for mainnet, testnet, devnet, and local") + { self.mainnet_core_status_online = false; self.testnet_core_status_online = false; + self.devnet_core_status_online = false; + self.local_core_status_online = false; } } @@ -253,6 +345,8 @@ impl ScreenLike for NetworkChooserScreen { BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( mainnet_chainlock, testnet_chainlock, + devnet_chainlock, + local_chainlock, )) => { match mainnet_chainlock { Some(_) => self.mainnet_core_status_online = true, @@ -262,6 +356,14 @@ impl ScreenLike for NetworkChooserScreen { Some(_) => self.testnet_core_status_online = true, None => self.testnet_core_status_online = false, } + match devnet_chainlock { + Some(_) => self.devnet_core_status_online = true, + None => self.devnet_core_status_online = false, + } + match local_chainlock { + Some(_) => self.local_core_status_online = true, + None => self.local_core_status_online = false, + } } _ => {} } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs new file mode 100644 index 000000000..feee64f65 --- /dev/null +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -0,0 +1,448 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::IdentityPublicKey; + +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// Internal states for the burn process. +#[derive(PartialEq)] +pub enum BurnTokensStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +pub struct BurnTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, // Info on which token/contract to burn from + selected_key: Option, + + // The user chooses how many tokens to burn + amount_to_burn: String, + + status: BurnTokensStatus, + error_message: Option, + + // Basic references + pub app_context: Arc, + + // Confirmation popup + show_confirmation_popup: bool, + + // For password-based wallet unlocking, if needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl BurnTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + // Find the local qualified identity that corresponds to `identity_token_balance.identity_id` + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found matching this token's identity."); + + // Grab a default signing key if possible + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + // Attempt to get an unlocked wallet reference + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + amount_to_burn: String::new(), + status: BurnTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + /// Renders a ComboBox or similar for selecting an authentication key + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("burn_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + /// Renders a text input for the user to specify an amount to burn + fn render_amount_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Amount to Burn:"); + ui.text_edit_singleline(&mut self.amount_to_burn); + }); + } + + /// Renders a confirm popup with the final "Are you sure?" step + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Burn") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Validate user input + let amount_ok = self.amount_to_burn.parse::().ok(); + if amount_ok.is_none() { + self.error_message = Some("Please enter a valid integer amount.".into()); + self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); + self.show_confirmation_popup = false; + return; + } + + ui.label(format!( + "Are you sure you want to burn {} tokens?", + self.amount_to_burn + )); + + ui.add_space(10.0); + + // Confirm button + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = BurnTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + // Dispatch the actual backend burn action + action = AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(TokenTask::BurnTokens { + owner_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + amount: amount_ok.unwrap(), + }), + BackendTask::TokenTask(TokenTask::QueryMyTokenBalances), + ], + BackendTasksExecutionMode::Sequential, + ); + } + + // Cancel button + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + /// Renders a simple "Success!" screen after completion + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Burn Successful!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + // Pop this screen and refresh + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for BurnTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + if message.contains("Successfully burned tokens") || message == "BurnTokens" { + self.status = BurnTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = BurnTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => { + // no-op + } + } + } + + fn refresh(&mut self) { + // If you need to reload local identity data or re-check keys + if let Ok(all_identities) = self.app_context.load_local_qualified_identities() { + if let Some(updated_identity) = all_identities + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated_identity; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Burn", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + // If we are in the "Complete" status, just show success screen + if self.status == BurnTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Burn Tokens"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::DARK_RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + // Must unlock before we can proceed + return; + } + } + + // 1) Key selection + ui.heading("1. Select the key to sign the Burn transaction"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // 2) Amount to burn + ui.heading("2. Amount to burn"); + ui.add_space(5.0); + self.render_amount_input(ui); + + ui.add_space(10.0); + + // Burn button + let button = egui::Button::new(RichText::new("Burn").color(Color32::WHITE)) + .fill(Color32::from_rgb(255, 0, 0)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Burn," show a popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Show in-progress or error messages + ui.add_space(10.0); + match &self.status { + BurnTokensStatus::NotStarted => { + // no-op + } + BurnTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Burning... elapsed: {} seconds", elapsed)); + } + BurnTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::DARK_RED, format!("Error: {}", msg)); + } + BurnTokensStatus::Complete => { + // handled above + } + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for BurnTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs new file mode 100644 index 000000000..30f2fab3c --- /dev/null +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -0,0 +1,467 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; + +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// Represents possible states in the “destroy frozen funds” flow +#[derive(PartialEq)] +pub enum DestroyFrozenFundsStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +/// A screen for destroying frozen funds of a particular token contract +pub struct DestroyFrozenFundsScreen { + /// Identity that is authorized to destroy + pub identity: QualifiedIdentity, + + /// Info on which token contract we’re dealing with + pub identity_token_balance: IdentityTokenBalance, + + /// The key used to sign the operation + selected_key: Option, + + /// The user must specify the identity ID whose frozen funds are to be destroyed + /// Typically some Identity that has been frozen by the system or a group + frozen_identity_id: String, + + status: DestroyFrozenFundsStatus, + error_message: Option, + + /// Basic references + pub app_context: Arc, + + /// Confirmation popup + show_confirmation_popup: bool, + + /// If password-based wallet unlocking is needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl DestroyFrozenFundsScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + // Locate the local identity that owns the token contract + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found matching this token's identity."); + + // Grab a suitable key + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + // Possibly get an unlocked wallet + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + frozen_identity_id: String::new(), + status: DestroyFrozenFundsStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + /// Renders a ComboBox or similar for selecting an authentication key + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("destroy_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + /// Renders the text input for specifying the “frozen identity” + fn render_frozen_identity_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Frozen Identity ID:"); + ui.text_edit_singleline(&mut self.frozen_identity_id); + }); + } + + /// Confirmation popup + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Destroy Frozen Funds") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Parse the user input into an Identifier + let maybe_frozen_id = Identifier::from_string_try_encodings( + &self.frozen_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + + if maybe_frozen_id.is_err() { + self.error_message = Some("Invalid frozen identity format".into()); + self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); + self.show_confirmation_popup = false; + return; + } + + let frozen_id = maybe_frozen_id.unwrap(); + + ui.label(format!( + "Are you sure you want to destroy the frozen funds of identity {}?", + self.frozen_identity_id + )); + + ui.add_space(10.0); + + // Confirm button + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = DestroyFrozenFundsStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + // Dispatch the actual backend destroy action + action = AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(TokenTask::DestroyFrozenFunds { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + frozen_identity: frozen_id, + }), + BackendTask::TokenTask(TokenTask::QueryMyTokenBalances), + ], + BackendTasksExecutionMode::Sequential, + ); + } + + // Cancel + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + /// Simple “Success” screen + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Successfully destroyed frozen funds!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for DestroyFrozenFundsScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + // If your backend returns "DestroyFrozenFunds" on success, + // or if there's a more descriptive success message: + if message.contains("Successfully destroyed frozen funds") + || message == "DestroyFrozenFunds" + { + self.status = DestroyFrozenFundsStatus::Complete; + } + } + MessageType::Error => { + self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => { + // no-op + } + } + } + + fn refresh(&mut self) { + // Reload the identity data if needed + if let Ok(all_identities) = self.app_context.load_local_qualified_identities() { + if let Some(updated_identity) = all_identities + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated_identity; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Destroy", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.status == DestroyFrozenFundsStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Destroy Frozen Funds"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::DARK_RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + if needed_unlock && !just_unlocked { + return; + } + } + + // Key selection + ui.heading("1. Select the key to sign the Destroy operation"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Frozen identity + ui.heading("2. Frozen identity to destroy funds from"); + ui.add_space(5.0); + self.render_frozen_identity_input(ui); + + ui.add_space(10.0); + + // Destroy button + let button = egui::Button::new(RichText::new("Destroy").color(Color32::WHITE)) + .fill(Color32::DARK_RED) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Destroy," show a popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Show in-progress or error messages + ui.add_space(10.0); + match &self.status { + DestroyFrozenFundsStatus::NotStarted => { + // no-op + } + DestroyFrozenFundsStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed = now - start_time; + ui.label(format!( + "Destroying frozen funds... elapsed: {} seconds", + elapsed + )); + } + DestroyFrozenFundsStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::DARK_RED, format!("Error: {}", msg)); + } + DestroyFrozenFundsStatus::Complete => { + // handled above + } + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for DestroyFrozenFundsScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs new file mode 100644 index 000000000..803adb191 --- /dev/null +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -0,0 +1,443 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; + +use crate::app::AppAction; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// Internal states for the freeze operation +#[derive(PartialEq)] +pub enum FreezeTokensStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +/// A UI Screen that allows freezing an identity’s tokens for a particular contract +pub struct FreezeTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, + selected_key: Option, + + /// The identity we want to freeze + freeze_identity_id: String, + + status: FreezeTokensStatus, + error_message: Option, + + // Basic references + pub app_context: Arc, + + // Confirmation popup + show_confirmation_popup: bool, + + // If password-based wallet unlocking is needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl FreezeTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found for this token"); + + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + freeze_identity_id: String::new(), + status: FreezeTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + /// Renders a ComboBox or similar for selecting an authentication key + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("freeze_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + /// Renders text input for the identity to freeze + fn render_freeze_identity_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Freeze Identity ID:"); + ui.text_edit_singleline(&mut self.freeze_identity_id); + }); + } + + /// Confirmation popup + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Freeze") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Validate user input + let parsed = Identifier::from_string_try_encodings( + &self.freeze_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + if parsed.is_err() { + self.error_message = Some("Please enter a valid identity ID.".into()); + self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); + self.show_confirmation_popup = false; + return; + } + let freeze_id = parsed.unwrap(); + + ui.label(format!( + "Are you sure you want to freeze identity {}?", + self.freeze_identity_id + )); + + ui.add_space(10.0); + + // Confirm + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = FreezeTokensStatus::WaitingForResult(now); + + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + // Dispatch to backend + action = + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::FreezeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + freeze_identity: freeze_id, + })); + } + + // Cancel + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + /// Success screen + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Frozen Successfully!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for FreezeTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + // Possibly check the exact message used in your backend + if message.contains("Successfully froze identity") || message == "FreezeTokens" { + self.status = FreezeTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => {} + } + } + + fn refresh(&mut self) { + // Reload identity if needed + if let Ok(all_identities) = self.app_context.load_local_qualified_identities() { + if let Some(updated_identity) = all_identities + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated_identity; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Freeze", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.status == FreezeTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Freeze Identity’s Tokens"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + // 1) Key selection + ui.heading("1. Select the key to sign the Freeze transition"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // 2) Identity to freeze + ui.heading("2. Enter the identity ID to freeze"); + ui.add_space(5.0); + self.render_freeze_identity_input(ui); + + ui.add_space(10.0); + + // Freeze button + let button = egui::Button::new(RichText::new("Freeze").color(Color32::WHITE)) + .fill(Color32::from_rgb(64, 64, 255)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Freeze," show popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Show in-progress or error messages + ui.add_space(10.0); + match &self.status { + FreezeTokensStatus::NotStarted => { + // no-op + } + FreezeTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Freezing... elapsed: {}s", elapsed)); + } + FreezeTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::RED, format!("Error: {}", msg)); + } + FreezeTokensStatus::Complete => { + // handled above + } + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for FreezeTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs new file mode 100644 index 000000000..b60a510b3 --- /dev/null +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -0,0 +1,502 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; + +use super::tokens_screen::{IdentityTokenBalance, TokensScreen, TokensSubscreen}; +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; + +/// Internal states for the mint process. +#[derive(PartialEq)] +pub enum MintTokensStatus { + NotStarted, + WaitingForResult(u64), // Use seconds or millis + ErrorMessage(String), + Complete, +} + +/// A UI Screen for minting tokens from an existing token contract +pub struct MintTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, // Info on which token contract to mint from + selected_key: Option, + + /// If the contract allows it, you can optionally mint to a specific identity. + /// If omitted, minted tokens are credited to the issuer's identity. + recipient_identity_id: String, + + amount_to_mint: String, + status: MintTokensStatus, + error_message: Option, + + /// Basic references + pub app_context: Arc, + + /// Confirmation popup + show_confirmation_popup: bool, + + // If needed for password-based wallet unlocking: + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl MintTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + // Find the local qualified identity that corresponds to `identity_token_balance.identity_id` + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found matching the token's identity"); + + // Grab a default key if possible + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + // Attempt to get an unlocked wallet reference + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + recipient_identity_id: "".to_string(), + amount_to_mint: "".to_string(), + status: MintTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + /// Renders a ComboBox or similar for selecting an authentication key + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("mint_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + /// Renders a text input for the user to specify an amount to mint + fn render_amount_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Amount to Mint:"); + ui.text_edit_singleline(&mut self.amount_to_mint); + + // Since it's minting, we often don't do "Max." + // But you could show a help text or put constraints if needed. + }); + } + + /// Renders an optional text input for the user to specify a "Recipient Identity" + fn render_recipient_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Recipient (Optional):"); + ui.text_edit_singleline(&mut self.recipient_identity_id); + }); + + // If empty, minted tokens go to the 'issuer' identity (self.identity). + } + + /// Renders a confirm popup with the final "Are you sure?" step + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Mint") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Validate user input + let amount_ok = self.amount_to_mint.parse::().ok(); + if amount_ok.is_none() { + self.error_message = Some("Please enter a valid amount.".into()); + self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); + self.show_confirmation_popup = false; + return; + } + + let maybe_identifier = if self.recipient_identity_id.trim().is_empty() { + Some(self.identity.identity.id()) + } else { + // Attempt to parse from base58 or hex + match Identifier::from_string_try_encodings( + &self.recipient_identity_id, + &[Encoding::Base58, Encoding::Hex], + ) { + Ok(id) => Some(id), + Err(_) => { + self.error_message = Some("Invalid recipient identity format.".into()); + self.status = + MintTokensStatus::ErrorMessage("Invalid recipient identity".into()); + self.show_confirmation_popup = false; + return; + } + } + }; + + ui.label(format!( + "Are you sure you want to mint {} token(s)?", + self.amount_to_mint + )); + + // If user provided a recipient: + if let Some(ref recipient_id) = maybe_identifier { + ui.label(format!( + "Recipient: {}", + recipient_id.to_string(Encoding::Base58) + )); + } else { + ui.label("No recipient specified; tokens will be minted to your identity."); + } + + ui.add_space(10.0); + + // Confirm button + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = MintTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + // Dispatch the actual backend mint action + action = AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(TokenTask::MintTokens { + sending_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + amount: amount_ok.unwrap(), + recipient_id: maybe_identifier, + }), + BackendTask::TokenTask(TokenTask::QueryMyTokenBalances), + ], + BackendTasksExecutionMode::Sequential, + ); + } + + // Cancel button + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + /// Renders a simple "Success!" screen after completion + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Mint Successful!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + // Pop this screen and refresh + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for MintTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + if message.contains("Successfully minted tokens") || message == "MintTokens" { + self.status = MintTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = MintTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => { + // no-op + } + } + } + + fn refresh(&mut self) { + // If you need to reload local identity data or re-check keys: + if let Ok(all_identities) = self.app_context.load_local_qualified_identities() { + if let Some(updated_identity) = all_identities + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated_identity; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + // Build a top panel + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Mint", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + // If we are in the "Complete" status, just show success screen + if self.status == MintTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Mint Tokens"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::DARK_RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario (similar to TransferTokens) + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + // Must unlock before we can proceed + return; + } + } + + // 1) Key selection + ui.heading("1. Select the key to sign the Mint transaction"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // 2) Amount to mint + ui.heading("2. Amount to mint"); + ui.add_space(5.0); + self.render_amount_input(ui); + + ui.separator(); + ui.add_space(10.0); + + // 3) (Optional) recipient identity + ui.heading("3. (Optional) Recipient identity"); + ui.add_space(5.0); + self.render_recipient_input(ui); + + ui.add_space(10.0); + // Mint button + let button = egui::Button::new(RichText::new("Mint").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If the user pressed "Mint," show a popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Show in-progress or error messages + ui.add_space(10.0); + match &self.status { + MintTokensStatus::NotStarted => { + // no-op + } + MintTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Minting... elapsed: {} seconds", elapsed)); + } + MintTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::DARK_RED, format!("Error: {}", msg)); + } + MintTokensStatus::Complete => { + // handled above + } + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for MintTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs new file mode 100644 index 000000000..4abf0b854 --- /dev/null +++ b/src/ui/tokens/mod.rs @@ -0,0 +1,9 @@ +pub mod burn_tokens_screen; +pub mod destroy_frozen_funds_screen; +pub mod freeze_tokens_screen; +pub mod mint_tokens_screen; +pub mod pause_tokens_screen; +pub mod resume_tokens_screen; +pub mod tokens_screen; +pub mod transfer_tokens_screen; +pub mod unfreeze_tokens_screen; diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs new file mode 100644 index 000000000..938abb19c --- /dev/null +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -0,0 +1,390 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; + +use crate::app::AppAction; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// Represents states for the pause flow +#[derive(PartialEq)] +pub enum PauseTokensStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +/// A UI screen that allows pausing all token-related actions for a contract +pub struct PauseTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, + selected_key: Option, + + status: PauseTokensStatus, + error_message: Option, + + // Basic references + pub app_context: Arc, + + // Confirmation popup + show_confirmation_popup: bool, + + // If password-based wallet unlocking is needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl PauseTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + // Find the local identity that owns this token contract + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found for this token’s identity."); + + // Grab a suitable key + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + // Possibly get an unlocked wallet + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + status: PauseTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("pause_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Pause") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + ui.label("Are you sure you want to pause all token actions for this contract?"); + ui.add_space(10.0); + + // Confirm + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = PauseTokensStatus::WaitingForResult(now); + + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + action = + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::PauseTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + })); + } + + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Paused Successfully!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for PauseTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + if message.contains("Paused") || message == "PauseTokens" { + self.status = PauseTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = PauseTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => { + // no-op + } + } + } + + fn refresh(&mut self) { + if let Ok(all) = self.app_context.load_local_qualified_identities() { + if let Some(updated) = all + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Pause", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.status == PauseTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Pause Token Contract"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + ui.heading("1. Select the key to sign the Pause transition"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + + let button = egui::Button::new(RichText::new("Pause").color(Color32::WHITE)) + .fill(Color32::from_rgb(192, 0, 0)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Pause," show popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + ui.add_space(10.0); + match &self.status { + PauseTokensStatus::NotStarted => {} + PauseTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Pausing... elapsed: {}s", elapsed)); + } + PauseTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::RED, format!("Error: {}", msg)); + } + PauseTokensStatus::Complete => {} + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for PauseTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs new file mode 100644 index 000000000..93d78daae --- /dev/null +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -0,0 +1,386 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; + +use crate::app::AppAction; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// States for the resume flow +#[derive(PartialEq)] +pub enum ResumeTokensStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +pub struct ResumeTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, + selected_key: Option, + + status: ResumeTokensStatus, + error_message: Option, + + // Basic references + pub app_context: Arc, + + // Confirmation popup + show_confirmation_popup: bool, + + // If password-based wallet unlocking is needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl ResumeTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found for this token’s identity."); + + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + status: ResumeTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("resume_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Resume") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + ui.label("Are you sure you want to resume normal token actions for this contract?"); + ui.add_space(10.0); + + // Confirm + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = ResumeTokensStatus::WaitingForResult(now); + + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + action = + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::ResumeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + })); + } + + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Resumed Successfully!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for ResumeTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + if message.contains("Resumed") || message == "ResumeTokens" { + self.status = ResumeTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => { + // no-op + } + } + } + + fn refresh(&mut self) { + if let Ok(all) = self.app_context.load_local_qualified_identities() { + if let Some(updated) = all + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Resume", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.status == ResumeTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Resume Token Contract"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + ui.heading("1. Select the key to sign the Resume transition"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + + let button = egui::Button::new(RichText::new("Resume").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 0)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Resume," show popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + ui.add_space(10.0); + match &self.status { + ResumeTokensStatus::NotStarted => {} + ResumeTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Resuming... elapsed: {}s", elapsed)); + } + ResumeTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::RED, format!("Error: {}", msg)); + } + ResumeTokensStatus::Complete => {} + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for ResumeTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/tokens_screen.rs b/src/ui/tokens/tokens_screen.rs new file mode 100644 index 000000000..4c3376671 --- /dev/null +++ b/src/ui/tokens/tokens_screen.rs @@ -0,0 +1,3614 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, RwLock}; + +use chrono::{DateTime, Utc}; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; +use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::v0::TokenDistributionRulesV0; +use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::TokenDistributionRules; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function::DistributionFunction; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::reward_distribution_type::RewardDistributionType; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::v0::TokenPerpetualDistributionV0; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::TokenPerpetualDistribution; +use dash_sdk::dpp::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; +use dash_sdk::dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; +use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; +use dash_sdk::dpp::data_contract::change_control_rules::v0::ChangeControlRulesV0; +use dash_sdk::dpp::data_contract::change_control_rules::ChangeControlRules; +use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; +use dash_sdk::dpp::data_contract::group::v0::GroupV0; +use dash_sdk::dpp::data_contract::group::{Group, GroupMemberPower, GroupRequiredPower}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::SecurityLevel; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use eframe::egui::{self, CentralPanel, Color32, Context, Frame, Margin, Ui}; +use egui::{Align, RichText}; +use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; +use egui_extras::{Column, TableBuilder}; +use crate::app::BackendTasksExecutionMode; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; + +use crate::app::{AppAction, DesiredAppAction}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, Screen, ScreenLike}; +use ordered_float::NotNan; + +use super::burn_tokens_screen::BurnTokensScreen; +use super::destroy_frozen_funds_screen::DestroyFrozenFundsScreen; +use super::freeze_tokens_screen::FreezeTokensScreen; +use super::mint_tokens_screen::MintTokensScreen; +use super::pause_tokens_screen::PauseTokensScreen; +use super::resume_tokens_screen::ResumeTokensScreen; +use super::transfer_tokens_screen::TransferTokensScreen; +use super::unfreeze_tokens_screen::UnfreezeTokensScreen; + +/// A token owned by an identity. +#[derive(Clone, Debug, PartialEq)] +pub struct IdentityTokenBalance { + pub token_identifier: Identifier, + pub token_name: String, + pub identity_id: Identifier, + pub balance: u64, + pub data_contract_id: Identifier, + pub token_position: u16, +} + +/// Which token sub-screen is currently showing. +#[derive(PartialEq)] +pub enum TokensSubscreen { + MyTokens, + SearchTokens, + TokenCreator, +} + +impl TokensSubscreen { + pub fn display_name(&self) -> &'static str { + match self { + Self::MyTokens => "My Tokens", + Self::SearchTokens => "Search Tokens", + Self::TokenCreator => "Token Creator", + } + } +} + +#[derive(PartialEq)] +pub enum RefreshingStatus { + Refreshing(u64), + NotRefreshing, +} + +/// Represents the status of the user’s search +#[derive(PartialEq, Eq, Clone)] +pub enum TokenSearchStatus { + NotStarted, + WaitingForResult(u64), + Complete, + ErrorMessage(String), +} + +#[derive(Debug, PartialEq)] +pub enum TokenCreatorStatus { + NotStarted, + WaitingForResult(u64), + Complete, + ErrorMessage(String), +} + +impl Default for TokenCreatorStatus { + fn default() -> Self { + Self::NotStarted + } +} + +/// Sorting columns +#[derive(Clone, Copy, PartialEq, Eq)] +enum SortColumn { + TokenName, + TokenID, + OwnerIdentity, + OwnerIdentityAlias, + Balance, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SortOrder { + Ascending, + Descending, +} + +#[derive(Clone, PartialEq, Eq, Default)] +pub struct ChangeControlRulesUI { + pub authorized: AuthorizedActionTakers, + pub authorized_identity: Option, + pub authorized_group: Option, + + pub admin_action_takers: AuthorizedActionTakers, + pub admin_identity: Option, + pub admin_group: Option, + + pub changing_authorized_action_takers_to_no_one_allowed: bool, + pub changing_admin_action_takers_to_no_one_allowed: bool, + pub self_changing_admin_action_takers_allowed: bool, +} + +impl ChangeControlRulesUI { + /// Renders the UI for a single action’s configuration (mint, burn, freeze, etc.) + pub fn render_control_change_rules_ui(&mut self, ui: &mut egui::Ui, action_name: &str) { + ui.collapsing(action_name, |ui| { + ui.add_space(3.0); + + egui::Grid::new("basic_token_info_grid") + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { + // Authorized action takers + ui.horizontal(|ui| { + ui.label("Authorized to perform action:"); + egui::ComboBox::from_id_salt(format!("Authorized {}", action_name)) + .selected_text(self.authorized.to_string()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.authorized, + AuthorizedActionTakers::NoOne, + "No One", + ); + ui.selectable_value( + &mut self.authorized, + AuthorizedActionTakers::ContractOwner, + "Contract Owner", + ); + ui.selectable_value( + &mut self.authorized, + AuthorizedActionTakers::Identity(Identifier::default()), + "Identity", + ); + ui.selectable_value( + &mut self.authorized, + AuthorizedActionTakers::MainGroup, + "Main Group", + ); + ui.selectable_value( + &mut self.authorized, + AuthorizedActionTakers::Group(0), + "Group", + ); + }); + + // If user selected Identity or Group, show text edit + match &mut self.authorized { + AuthorizedActionTakers::Identity(_) => { + self.authorized_identity.get_or_insert_with(String::new); + if let Some(ref mut id) = self.authorized_identity { + ui.add( + egui::TextEdit::singleline(id).hint_text("Enter base58 id"), + ); + } + } + AuthorizedActionTakers::Group(_) => { + self.authorized_group.get_or_insert_with(|| "0".to_owned()); + if let Some(ref mut group_str) = self.authorized_group { + ui.add( + egui::TextEdit::singleline(group_str) + .hint_text("Group contract position"), + ); + } + } + _ => {} + } + }); + ui.end_row(); + + // Admin action takers + ui.horizontal(|ui| { + ui.label("Authorized to change rules:"); + egui::ComboBox::from_id_salt(format!("Admin {}", action_name)) + .selected_text(self.admin_action_takers.to_string()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.admin_action_takers, + AuthorizedActionTakers::NoOne, + "No One", + ); + ui.selectable_value( + &mut self.admin_action_takers, + AuthorizedActionTakers::ContractOwner, + "Contract Owner", + ); + ui.selectable_value( + &mut self.admin_action_takers, + AuthorizedActionTakers::Identity(Identifier::default()), + "Identity", + ); + ui.selectable_value( + &mut self.admin_action_takers, + AuthorizedActionTakers::MainGroup, + "Main Group", + ); + ui.selectable_value( + &mut self.admin_action_takers, + AuthorizedActionTakers::Group(0), + "Group", + ); + }); + + match &mut self.admin_action_takers { + AuthorizedActionTakers::Identity(_) => { + self.admin_identity.get_or_insert_with(String::new); + if let Some(ref mut id) = self.admin_identity { + ui.add( + egui::TextEdit::singleline(id).hint_text("Enter base58 id"), + ); + } + } + AuthorizedActionTakers::Group(_) => { + self.admin_group.get_or_insert_with(|| "0".to_owned()); + if let Some(ref mut group_str) = self.admin_group { + ui.add( + egui::TextEdit::singleline(group_str) + .hint_text("Group contract position"), + ); + } + } + _ => {} + } + }); + ui.end_row(); + + // Booleans + ui.checkbox( + &mut self.changing_authorized_action_takers_to_no_one_allowed, + "Changing authorized action takers to no one allowed", + ); + ui.end_row(); + + ui.checkbox( + &mut self.changing_admin_action_takers_to_no_one_allowed, + "Changing admin action takers to no one allowed", + ); + ui.end_row(); + + ui.checkbox( + &mut self.self_changing_admin_action_takers_allowed, + "Self-changing admin action takers allowed", + ); + ui.end_row(); + }); + + ui.add_space(3.0); + }); + } + + pub fn to_change_control_rules( + &mut self, + action_name: &str, + ) -> Result { + // 1) Update self.authorized if it’s Identity or Group + match self.authorized { + AuthorizedActionTakers::Identity(_) => { + if let Some(ref id_str) = self.authorized_identity { + let parsed = + Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { + format!( + "Invalid base58 identifier for {} authorized identity", + action_name + ) + })?; + self.authorized = AuthorizedActionTakers::Identity(parsed); + } + } + AuthorizedActionTakers::Group(_) => { + if let Some(ref group_str) = self.authorized_group { + let parsed = group_str.parse::().map_err(|_| { + format!( + "Invalid group contract position for {} authorized group", + action_name + ) + })?; + self.authorized = AuthorizedActionTakers::Group(parsed); + } + } + _ => {} + } + + // 2) Update self.admin_action_takers if it’s Identity or Group + match self.admin_action_takers { + AuthorizedActionTakers::Identity(_) => { + if let Some(ref id_str) = self.admin_identity { + let parsed = + Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { + format!( + "Invalid base58 identifier for {} admin identity", + action_name + ) + })?; + self.admin_action_takers = AuthorizedActionTakers::Identity(parsed); + } + } + AuthorizedActionTakers::Group(_) => { + if let Some(ref group_str) = self.admin_group { + let parsed = group_str.parse::().map_err(|_| { + format!( + "Invalid group contract position for {} admin group", + action_name + ) + })?; + self.admin_action_takers = AuthorizedActionTakers::Group(parsed); + } + } + _ => {} + } + + // 3) Construct the ChangeControlRules + let rules = ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: self.authorized.clone(), + admin_action_takers: self.admin_action_takers.clone(), + changing_authorized_action_takers_to_no_one_allowed: self + .changing_authorized_action_takers_to_no_one_allowed, + changing_admin_action_takers_to_no_one_allowed: self + .changing_admin_action_takers_to_no_one_allowed, + self_changing_admin_action_takers_allowed: self + .self_changing_admin_action_takers_allowed, + }); + + Ok(rules) + } +} + +/// A lightweight enum for the user’s choice of distribution type +#[derive(Debug, Clone, PartialEq)] +pub enum PerpetualDistributionIntervalTypeUI { + None, + BlockBased, + TimeBased, + EpochBased, +} + +/// A lightweight enum for the user’s choice of distribution function +#[derive(Debug, Clone, PartialEq)] +pub enum DistributionFunctionUI { + FixedAmount, + StepDecreasingAmount, + LinearInteger, + LinearFloat, + PolynomialInteger, + PolynomialFloat, + Exponential, + Logarithmic, + Stepwise, +} + +/// A lightweight enum for the user’s recipient selection +#[derive(Debug, Clone, PartialEq)] +pub enum TokenDistributionRecipientUI { + ContractOwner, + Identity, + EvonodesByParticipation, +} + +#[derive(Default, Clone)] +pub struct DistributionEntry { + /// The block timestamp or block height when distribution occurs + pub timestamp_str: String, + + /// The base58 identity to receive distribution + pub identity_str: String, + + /// The distribution amount + pub amount_str: String, +} + +#[derive(Default, Clone)] +pub struct GroupMemberUI { + /// The base58 identity for this member + pub identity_str: String, + /// The power (u32) as a string for user input + pub power_str: String, +} + +#[derive(Default, Clone)] +pub struct GroupConfigUI { + /// The "position" in the contract's groups map (like 0, 1, etc.) + pub group_position_str: String, + /// Required power for the group (u32), user enters as string + pub required_power_str: String, + /// The members for this group + pub members: Vec, +} + +impl GroupConfigUI { + /// Try converting this UI struct into a real `Group` (specifically `Group::V0`). + /// We also return the `u16` key that this group should be inserted under in the contract’s `groups` map. + fn parse_into_group(&self) -> Result<(u16, Group), String> { + // 1) Parse group position + let group_position = self.group_position_str.parse::().map_err(|_| { + format!( + "Invalid group position: '{}'. Must be an unsigned integer.", + self.group_position_str + ) + })?; + + // 2) Parse required power + let required_power = self.required_power_str.parse::().map_err(|_| { + format!( + "Invalid required power: '{}'. Must be an unsigned integer.", + self.required_power_str + ) + })? as GroupRequiredPower; + + // 3) Build a BTreeMap for members + let mut members_map = BTreeMap::new(); + for (i, member) in self.members.iter().enumerate() { + // A) Parse member identity from base58 + let id = + Identifier::from_string(&member.identity_str, Encoding::Base58).map_err(|_| { + format!( + "Member #{}: invalid base58 identity '{}'", + i + 1, + member.identity_str + ) + })?; + + // B) Parse power + let power = + member.power_str.parse::().map_err(|_| { + format!("Member #{}: invalid power '{}'", i + 1, member.power_str) + })? as GroupMemberPower; + + // Insert into the map + members_map.insert(id, power); + } + + // 4) Construct Group::V0 + let group_v0 = GroupV0 { + members: members_map, + required_power, + }; + + // 5) Return as (group_position, Group::V0 wrapped in Group::V0()) + Ok((group_position, Group::V0(group_v0))) + } +} + +#[derive(Clone)] +/// All arguments needed by `build_data_contract_v1_with_one_token`. +pub struct TokenBuildArgs { + pub identity_id: Identifier, + + pub token_name: String, + pub should_capitalize: bool, + pub decimals: u16, + pub base_supply: u64, + pub max_supply: Option, + pub start_paused: bool, + pub keeps_history: bool, + pub main_control_group: Option, + + pub manual_minting_rules: ChangeControlRules, + pub manual_burning_rules: ChangeControlRules, + pub freeze_rules: ChangeControlRules, + pub unfreeze_rules: ChangeControlRules, + pub destroy_frozen_funds_rules: ChangeControlRules, + pub emergency_action_rules: ChangeControlRules, + pub max_supply_change_rules: ChangeControlRules, + pub conventions_change_rules: ChangeControlRules, + pub main_control_group_change_authorized: AuthorizedActionTakers, + + pub distribution_rules: TokenDistributionRules, + pub groups: BTreeMap, +} + +/// The main, combined TokensScreen: +/// - Displays token balances or a search UI +/// - Allows reordering of tokens if desired +pub struct TokensScreen { + pub app_context: Arc, + pub tokens_subscreen: TokensSubscreen, + my_tokens: Arc>>, + pub selected_token_id: Option, + show_token_info: Option, + backend_message: Option<(String, MessageType, DateTime)>, + pending_backend_task: Option, + refreshing_status: RefreshingStatus, + + // Token Search + token_search_query: Option, + search_results: Arc>>, + token_search_status: TokenSearchStatus, + search_current_page: usize, + search_has_next_page: bool, + next_cursors: Vec, + previous_cursors: Vec, + + // Sorting + sort_column: SortColumn, + sort_order: SortOrder, + use_custom_order: bool, + + // Remove token + confirm_remove_identity_token_balance_popup: bool, + identity_token_balance_to_remove: Option, + confirm_remove_token_popup: bool, + token_to_remove: Option, + + // ==================================== + // Token Creator + // ==================================== + show_pop_up_info: Option, + selected_identity: Option, + selected_key: Option, + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, + token_name_input: String, + should_capitalize_input: bool, + decimals_input: String, + base_supply_input: String, + max_supply_input: String, + start_as_paused_input: bool, + main_control_group_input: String, + show_token_creator_confirmation_popup: bool, + token_creator_status: TokenCreatorStatus, + token_creator_error_message: Option, + token_keeps_history: bool, + groups_ui: Vec, + cached_build_args: Option, + show_json_popup: bool, + json_popup_text: String, + + // Action Rules + manual_minting_rules: ChangeControlRulesUI, + manual_burning_rules: ChangeControlRulesUI, + freeze_rules: ChangeControlRulesUI, + unfreeze_rules: ChangeControlRulesUI, + destroy_frozen_funds_rules: ChangeControlRulesUI, + emergency_action_rules: ChangeControlRulesUI, + max_supply_change_rules: ChangeControlRulesUI, + conventions_change_rules: ChangeControlRulesUI, + authorized_main_control_group_change: AuthorizedActionTakers, + main_control_group_change_authorized_identity: Option, + main_control_group_change_authorized_group: Option, + + // Perpetual Distribution + pub enable_perpetual_distribution: bool, + pub perpetual_distribution_rules: ChangeControlRulesUI, + pub perpetual_dist_type: PerpetualDistributionIntervalTypeUI, + pub perpetual_dist_interval_input: String, + pub perpetual_dist_function: DistributionFunctionUI, + pub perpetual_dist_recipient: TokenDistributionRecipientUI, + pub perpetual_dist_recipient_identity_input: Option, + // Pre-programmed distribution + pub enable_pre_programmed_distribution: bool, + pub pre_programmed_distributions: Vec, + + // New Tokens Destination Identity + pub new_tokens_destination_identity_enabled: bool, + pub new_tokens_destination_identity: String, + pub new_tokens_destination_identity_rules: ChangeControlRulesUI, + + // Minting Allow Choosing Destination + pub minting_allow_choosing_destination: bool, + pub minting_allow_choosing_destination_rules: ChangeControlRulesUI, + + // --- FixedAmount --- + pub fixed_amount_input: String, + + // --- StepDecreasingAmount --- + pub step_count_input: String, + pub decrease_per_interval_input: String, + pub step_dec_amount_input: String, + + // --- LinearInteger --- + pub linear_int_a_input: String, + pub linear_int_b_input: String, + + // --- LinearFloat --- + pub linear_float_a_input: String, + pub linear_float_b_input: String, + + // --- PolynomialInteger --- + pub poly_int_a_input: String, + pub poly_int_n_input: String, + pub poly_int_b_input: String, + + // --- PolynomialFloat --- + pub poly_float_a_input: String, + pub poly_float_n_input: String, + pub poly_float_b_input: String, + + // --- Exponential --- + pub exp_a_input: String, + pub exp_b_input: String, + pub exp_c_input: String, + + // --- Logarithmic --- + pub log_a_input: String, + pub log_b_input: String, + pub log_c_input: String, + + // --- Stepwise --- + // If you want multiple (block, amount) pairs, store them in a Vec. + // Each tuple in the Vec can be (String, String) for block + amount + // or however you prefer to represent it. + pub stepwise_steps: Vec<(String, String)>, +} + +impl TokensScreen { + pub fn new(app_context: &Arc, tokens_subscreen: TokensSubscreen) -> Self { + let my_tokens = Arc::new(Mutex::new( + app_context.identity_token_balances().unwrap_or_default(), + )); + + let mut screen = Self { + app_context: app_context.clone(), + my_tokens, + selected_token_id: None, + show_token_info: None, + token_search_query: None, + token_search_status: TokenSearchStatus::NotStarted, + search_current_page: 1, + search_has_next_page: false, + next_cursors: vec![], + previous_cursors: vec![], + search_results: Arc::new(Mutex::new(Vec::new())), + backend_message: None, + sort_column: SortColumn::TokenName, + sort_order: SortOrder::Ascending, + use_custom_order: false, + pending_backend_task: None, + tokens_subscreen, + refreshing_status: RefreshingStatus::NotRefreshing, + + // Remove token + confirm_remove_identity_token_balance_popup: false, + identity_token_balance_to_remove: None, + confirm_remove_token_popup: false, + token_to_remove: None, + + // Token Creator + show_pop_up_info: None, + selected_identity: None, + selected_key: None, + selected_wallet: None, + wallet_password: String::new(), + show_password: false, + show_token_creator_confirmation_popup: false, + token_creator_status: TokenCreatorStatus::NotStarted, + token_creator_error_message: None, + token_name_input: String::new(), + should_capitalize_input: false, + decimals_input: 8.to_string(), + base_supply_input: TokenConfigurationV0::default_most_restrictive() + .base_supply() + .to_string(), + max_supply_input: String::new(), + start_as_paused_input: false, + token_keeps_history: false, + main_control_group_input: String::new(), + groups_ui: Vec::new(), + cached_build_args: None, + show_json_popup: false, + json_popup_text: String::new(), + + // Action rules + manual_minting_rules: ChangeControlRulesUI::default(), + manual_burning_rules: ChangeControlRulesUI::default(), + freeze_rules: ChangeControlRulesUI::default(), + unfreeze_rules: ChangeControlRulesUI::default(), + destroy_frozen_funds_rules: ChangeControlRulesUI::default(), + emergency_action_rules: ChangeControlRulesUI::default(), + max_supply_change_rules: ChangeControlRulesUI::default(), + conventions_change_rules: ChangeControlRulesUI::default(), + + // Main control group change rules + authorized_main_control_group_change: AuthorizedActionTakers::NoOne, + main_control_group_change_authorized_identity: None, + main_control_group_change_authorized_group: None, + + // Distribution (perpetual) toggles/fields + enable_perpetual_distribution: false, + perpetual_distribution_rules: ChangeControlRulesUI::default(), + + // Which distribution type is selected? + perpetual_dist_type: PerpetualDistributionIntervalTypeUI::None, + + // Block-based / time-based / epoch-based inputs + perpetual_dist_interval_input: String::new(), + + // Distribution function selection + perpetual_dist_function: DistributionFunctionUI::FixedAmount, + fixed_amount_input: String::new(), + step_count_input: String::new(), + decrease_per_interval_input: String::new(), + step_dec_amount_input: String::new(), + linear_int_a_input: String::new(), + linear_int_b_input: String::new(), + linear_float_a_input: String::new(), + linear_float_b_input: String::new(), + poly_int_a_input: String::new(), + poly_int_n_input: String::new(), + poly_int_b_input: String::new(), + poly_float_a_input: String::new(), + poly_float_n_input: String::new(), + poly_float_b_input: String::new(), + exp_a_input: String::new(), + exp_b_input: String::new(), + exp_c_input: String::new(), + log_a_input: String::new(), + log_b_input: String::new(), + log_c_input: String::new(), + stepwise_steps: Vec::new(), + + // Similarly for identity recipients, you might store: + perpetual_dist_recipient: TokenDistributionRecipientUI::ContractOwner, + perpetual_dist_recipient_identity_input: None, + + // Pre-programmed distribution + enable_pre_programmed_distribution: false, + // Possibly let them paste in a JSON schedule, or some minimal UI for (timestamp -> {id -> amount}). + // For an example, we'll keep it simple: + pre_programmed_distributions: Vec::new(), + + // new_tokens_destination_identity + new_tokens_destination_identity_enabled: false, + new_tokens_destination_identity: String::new(), + new_tokens_destination_identity_rules: ChangeControlRulesUI::default(), + + // minting_allow_choosing_destination + minting_allow_choosing_destination: false, + minting_allow_choosing_destination_rules: ChangeControlRulesUI::default(), + }; + + if let Ok(saved_ids) = screen.app_context.db.load_token_order() { + screen.reorder_vec_to(saved_ids); + screen.use_custom_order = true; + } + + screen + } + + // ───────────────────────────────────────────────────────────────── + // Reordering + // ───────────────────────────────────────────────────────────────── + + /// Reorder `my_tokens` to match a given list of (token_id, identity_id). + fn reorder_vec_to(&self, new_order: Vec<(Identifier, Identifier)>) { + let mut lock = self.my_tokens.lock().unwrap(); + for (desired_idx, (token_id, identity_id)) in new_order.iter().enumerate() { + if let Some(current_idx) = lock + .iter() + .position(|t| t.token_identifier == *token_id && t.identity_id == *identity_id) + { + if current_idx != desired_idx && current_idx < lock.len() { + lock.swap(current_idx, desired_idx); + } + } + } + } + + /// Save the current vector's order of token IDs to the DB + fn save_current_order(&self) { + let lock = self.my_tokens.lock().unwrap(); + let all_ids = lock + .iter() + .map(|token| (token.token_identifier.clone(), token.identity_id.clone())) + .collect::>(); + drop(lock); + self.app_context + .db + .save_token_order(all_ids) + .or_else(|e| { + eprintln!("Error saving token order: {}", e); + Err(e) + }) + .ok(); + } + + // ───────────────────────────────────────────────────────────────── + // Sorting + // ───────────────────────────────────────────────────────────────── + + /// Sort the vector by the user-specified column/order, overriding any custom order. + fn sort_vec(&self, list: &mut [IdentityTokenBalance]) { + list.sort_by(|a, b| { + let ordering = match self.sort_column { + SortColumn::Balance => a.balance.cmp(&b.balance), + SortColumn::OwnerIdentity => a.identity_id.cmp(&b.identity_id), + SortColumn::OwnerIdentityAlias => { + let alias_a = self + .app_context + .get_alias(&a.identity_id) + .expect("Expected to get alias") + .unwrap_or("".to_string()); + let alias_b = self + .app_context + .get_alias(&b.identity_id) + .expect("Expected to get alias") + .unwrap_or("".to_string()); + alias_a.cmp(&alias_b) + } + SortColumn::TokenName => a.token_name.cmp(&b.token_name), + SortColumn::TokenID => a.token_identifier.cmp(&b.token_identifier), + }; + match self.sort_order { + SortOrder::Ascending => ordering, + SortOrder::Descending => ordering.reverse(), + } + }); + self.save_current_order(); + } + + fn sort_vec_of_groups(&self, list: &mut [(Identifier, String, u64)]) { + list.sort_by(|a, b| { + let ordering = match self.sort_column { + SortColumn::Balance => a.2.cmp(&b.2), + SortColumn::TokenName => a.1.cmp(&b.1), + SortColumn::TokenID => a.0.cmp(&b.0), + _ => a.0.cmp(&b.0), + }; + match self.sort_order { + SortOrder::Ascending => ordering, + SortOrder::Descending => ordering.reverse(), + } + }); + } + + fn toggle_sort(&mut self, column: SortColumn) { + self.use_custom_order = false; + if self.sort_column == column { + self.sort_order = match self.sort_order { + SortOrder::Ascending => SortOrder::Descending, + SortOrder::Descending => SortOrder::Ascending, + }; + self.save_current_order(); + } else { + self.sort_column = column; + self.sort_order = SortOrder::Ascending; + self.save_current_order(); + } + } + + /// Group all IdentityTokenBalance objects by token_identifier. + /// Returns a Vec of (token_identifier, token_name, total_balance). + fn group_tokens_by_identifier( + &self, + tokens: &[IdentityTokenBalance], + ) -> Vec<(Identifier, String, u64)> { + let mut map: HashMap = HashMap::new(); + for tb in tokens { + let entry = map.entry(tb.token_identifier.clone()).or_insert_with(|| { + // Store (token_name, running_total_balance) + (tb.token_name.clone(), 0u64) + }); + entry.1 += tb.balance; + } + + // Convert to a vec for display + let mut result = Vec::new(); + for (identifier, (name, total_balance)) in map { + result.push((identifier, name, total_balance)); + } + // Sort by token name, for example + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + // ───────────────────────────────────────────────────────────────── + // Message handling + // ───────────────────────────────────────────────────────────────── + + fn dismiss_message(&mut self) { + self.backend_message = None; + } + + fn check_error_expiration(&mut self) { + if let Some((_, _, timestamp)) = &self.backend_message { + let now = Utc::now(); + let elapsed = now.signed_duration_since(*timestamp); + if elapsed.num_seconds() >= 10 { + self.dismiss_message(); + } + } + } + + // ───────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────── + + /// Renders the top-level token list (one row per unique token). + /// When the user clicks on a token, we set `selected_token_id`. + fn render_token_list(&mut self, ui: &mut Ui, tokens: &[IdentityTokenBalance]) { + let mut grouped = self.group_tokens_by_identifier(tokens); + if !self.use_custom_order { + self.sort_vec_of_groups(&mut grouped); + } + + // Allocate space for refreshing indicator + let refreshing_height = 33.0; + let mut max_scroll_height = if let RefreshingStatus::Refreshing(_) = self.refreshing_status + { + ui.available_height() - refreshing_height + } else { + ui.available_height() + }; + + // Allocate space for backend message + let backend_message_height = 40.0; + if let Some((_, _, _)) = self.backend_message.clone() { + max_scroll_height -= backend_message_height; + } + + // A simple table with columns: [Token Name | Token ID | Total Balance] + egui::ScrollArea::vertical() + .max_height(max_scroll_height) + .show(ui, |ui| { + Frame::group(ui.style()) + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.inactive.bg_stroke.color, + )) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(Align::Center)) + .column(Column::initial(150.0).resizable(true)) // Token Name + .column(Column::initial(200.0).resizable(true)) // Token ID + .column(Column::initial(80.0).resizable(true)) // Total Balance + .column(Column::initial(80.0).resizable(true)) // Actions + // .column(Column::initial(80.0).resizable(true)) // Token Info + .header(30.0, |mut header| { + header.col(|ui| { + if ui.button("Token Name").clicked() { + self.toggle_sort(SortColumn::TokenName); + } + }); + header.col(|ui| { + if ui.button("Token ID").clicked() { + self.toggle_sort(SortColumn::TokenID); + } + }); + header.col(|ui| { + if ui.button("Total Balance").clicked() { + self.toggle_sort(SortColumn::Balance); + } + }); + header.col(|ui| { + ui.label("Actions"); + }); + // header.col(|ui| { + // ui.label("Token Info"); + // }); + }) + .body(|mut body| { + for (token_id, token_name, total_balance) in grouped { + body.row(25.0, |mut row| { + row.col(|ui| { + // By making the label into a button or using `ui.selectable_label`, + // we can respond to clicks. + if ui.button(&token_name).clicked() { + self.selected_token_id = Some(token_id.clone()); + } + }); + row.col(|ui| { + ui.label(token_id.to_string(Encoding::Base58)); + }); + row.col(|ui| { + ui.label(total_balance.to_string()); + }); + row.col(|ui| { + // Remove + if ui + .button("X") + .on_hover_text("Remove token from DET") + .clicked() + { + self.confirm_remove_token_popup = true; + self.token_to_remove = Some(token_id.clone()); + } + }); + // row.col(|ui| { + // if ui.button("Info").clicked() { + // self.show_token_info = Some(token_id.clone()); + // } + // }); + }); + } + }); + }); + }); + } + + /// Renders details for the selected token_id: a row per identity that holds that token. + fn render_token_details(&mut self, ui: &mut Ui, tokens: &[IdentityTokenBalance]) -> AppAction { + let mut action = AppAction::None; + + let token_id = self.selected_token_id.as_ref().unwrap(); + + // Filter out only the IdentityTokenBalance for this token_id + let mut detail_list: Vec = tokens + .iter() + .filter(|t| &t.token_identifier == token_id) + .cloned() + .collect(); + if !self.use_custom_order { + self.sort_vec(&mut detail_list); + } + + // This is basically your old `render_table_my_token_balances` logic, but + // limited to just the single token. + // Allocate space for refreshing indicator + let refreshing_height = 33.0; + let mut max_scroll_height = if let RefreshingStatus::Refreshing(_) = self.refreshing_status + { + ui.available_height() - refreshing_height + } else { + ui.available_height() + }; + + // Allocate space for backend message + let backend_message_height = 40.0; + if let Some((_, _, _)) = self.backend_message.clone() { + max_scroll_height -= backend_message_height; + } + + // A simple table with columns: [Token Name | Token ID | Total Balance] + egui::ScrollArea::vertical() + .max_height(max_scroll_height) + .show(ui, |ui| { + Frame::group(ui.style()) + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.inactive.bg_stroke.color, + )) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(Align::Center)) + .column(Column::initial(60.0).resizable(true)) // Identity Alias + .column(Column::initial(200.0).resizable(true)) // Identity ID + .column(Column::initial(60.0).resizable(true)) // Balance + .column(Column::initial(200.0).resizable(true)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + if ui.button("Identity Alias").clicked() { + self.toggle_sort(SortColumn::OwnerIdentityAlias); + } + }); + header.col(|ui| { + if ui.button("Identity ID").clicked() { + self.toggle_sort(SortColumn::OwnerIdentity); + } + }); + header.col(|ui| { + if ui.button("Balance").clicked() { + self.toggle_sort(SortColumn::Balance); + } + }); + header.col(|ui| { + ui.label("Actions"); + }); + }) + .body(|mut body| { + for itb in &detail_list { + body.row(25.0, |mut row| { + row.col(|ui| { + // Show identity alias or ID + if let Some(alias) = self + .app_context + .get_alias(&itb.identity_id) + .expect("Expected to get alias") + { + ui.label(alias); + } else { + ui.label("-"); + } + }); + row.col(|ui| { + ui.label(itb.identity_id.to_string(Encoding::Base58)); + }); + row.col(|ui| { + ui.label(itb.balance.to_string()); + }); + row.col(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + + // Transfer + if ui.button("Transfer").clicked() { + action = AppAction::AddScreen( + Screen::TransferTokensScreen( + TransferTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + } + + // Expandable advanced actions menu + ui.menu_button("...", |ui| { + if ui.button("Mint").clicked() { + action = AppAction::AddScreen( + Screen::MintTokensScreen( + MintTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Burn").clicked() { + action = AppAction::AddScreen( + Screen::BurnTokensScreen( + BurnTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Freeze").clicked() { + action = AppAction::AddScreen( + Screen::FreezeTokensScreen( + FreezeTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Destroy").clicked() { + action = AppAction::AddScreen( + Screen::DestroyFrozenFundsScreen( + DestroyFrozenFundsScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Unfreeze").clicked() { + action = AppAction::AddScreen( + Screen::UnfreezeTokensScreen( + UnfreezeTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Pause").clicked() { + action = AppAction::AddScreen( + Screen::PauseTokensScreen( + PauseTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + if ui.button("Resume").clicked() { + action = AppAction::AddScreen( + Screen::ResumeTokensScreen( + ResumeTokensScreen::new( + itb.clone(), + &self.app_context, + ), + ), + ); + ui.close_menu(); + } + }); + + // Remove + if ui + .button("X") + .on_hover_text( + "Remove identity token balance from DET", + ) + .clicked() + { + self.confirm_remove_identity_token_balance_popup = true; + self.identity_token_balance_to_remove = Some(itb.clone()); + } + }); + }); + }); + } + }); + }); + }); + + action + } + + fn render_token_search(&mut self, ui: &mut Ui) -> AppAction { + let action = AppAction::None; + + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.heading("Coming Soon"); + ui.add_space(5.0); + + // ui.add_space(10.0); + // ui.label("Search for tokens by keyword, name, or ID."); + // ui.add_space(5.0); + + // ui.horizontal(|ui| { + // ui.label("Search by keyword(s):"); + // ui.text_edit_singleline(self.token_search_query.get_or_insert_with(String::new)); + // if ui.button("Go").clicked() { + // // 1) Clear old results, set status + // let now = Utc::now().timestamp() as u64; + // self.token_search_status = TokenSearchStatus::WaitingForResult(now); + // { + // let mut sr = self.search_results.lock().unwrap(); + // sr.clear(); + // } + // self.search_current_page = 1; + // self.next_cursors.clear(); + // self.previous_cursors.clear(); + // self.search_has_next_page = false; + + // // 2) Dispatch backend request + // let query_string = self + // .token_search_query + // .as_ref() + // .map(|s| s.clone()) + // .unwrap_or_default(); + + // // Example: if you want paged results from the start: + // action = AppAction::BackendTask(BackendTask::TokenTask( + // TokenTask::QueryTokensByKeywordPage(query_string, None), + // )); + // } + // }); + }); + + // ui.separator(); + // ui.add_space(10.0); + + // // Show results or messages + // match self.token_search_status { + // TokenSearchStatus::WaitingForResult(start_time) => { + // let now = Utc::now().timestamp() as u64; + // let elapsed = now - start_time; + // ui.label(format!("Searching... Time so far: {} seconds", elapsed)); + // ui.add(egui::widgets::Spinner::default().color(Color32::from_rgb(0, 128, 255))); + // } + // TokenSearchStatus::Complete => { + // // Render the results table + // let tokens = self.search_results.lock().unwrap().clone(); + // if tokens.is_empty() { + // ui.label("No tokens match your search."); + // } else { + // // Possibly add a filter input above the table, if you like + // action |= self.render_search_results_table(ui, &tokens); + // } + + // // Then pagination controls + // ui.horizontal(|ui| { + // // If not on page 1, we can show a “Prev” button + // if self.search_current_page > 1 { + // if ui.button("Previous Page").clicked() { + // action |= self.goto_previous_search_page(); + // } + // } + + // ui.label(format!("Page {}", self.search_current_page)); + + // // If has_next_page, show “Next Page” button + // if self.search_has_next_page { + // if ui.button("Next Page").clicked() { + // action |= self.goto_next_search_page(); + // } + // } + // }); + // } + // TokenSearchStatus::ErrorMessage(ref e) => { + // ui.colored_label(Color32::DARK_RED, format!("Error: {}", e)); + // } + // TokenSearchStatus::NotStarted => { + // ui.label("Enter keywords above and click Go to search tokens."); + // } + // } + + action + } + + fn render_search_results_table( + &mut self, + ui: &mut Ui, + search_results: &[IdentityTokenBalance], + ) -> AppAction { + let action = AppAction::None; + + // In your DocumentQueryScreen code, you also had a ScrollArea + egui::ScrollArea::vertical().show(ui, |ui| { + Frame::group(ui.style()) + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.inactive.bg_stroke.color, + )) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(Align::Center)) + .column(Column::initial(80.0).resizable(true)) // Token Name + .column(Column::initial(330.0).resizable(true)) // Identity + .column(Column::initial(60.0).resizable(true)) // Balance + .column(Column::initial(80.0).resizable(true)) // Action + .header(30.0, |mut header| { + header.col(|ui| { + if ui.button("Token Name").clicked() { + self.toggle_sort(SortColumn::TokenName); + } + }); + header.col(|ui| { + if ui.button("Token ID").clicked() { + self.toggle_sort(SortColumn::TokenID); + } + }); + header.col(|ui| { + if ui.button("Balance").clicked() { + self.toggle_sort(SortColumn::Balance); + } + }); + header.col(|ui| { + ui.label("Action"); + }); + }) + .body(|mut body| { + for token in search_results { + body.row(25.0, |mut row| { + row.col(|ui| { + ui.label(&token.token_name); + }); + row.col(|ui| { + ui.label(token.identity_id.to_string(Encoding::Base58)); + }); + row.col(|ui| { + ui.label(token.balance.to_string()); + }); + row.col(|ui| { + if ui.button("Add").clicked() { + // Add to my_tokens + self.add_token_to_my_tokens(token.clone()); + } + }); + }); + } + }); + }); + }); + + action + } + + pub fn render_token_creator(&mut self, ui: &mut egui::Ui) -> AppAction { + let mut action = AppAction::None; + + // 1) If we've successfully completed contract creation, show a success UI + if self.token_creator_status == TokenCreatorStatus::Complete { + self.render_token_creator_success_screen(ui); + return action; + } + + // Allocate space for refreshing indicator + let refreshing_height = 33.0; + let mut max_scroll_height = if let RefreshingStatus::Refreshing(_) = self.refreshing_status + { + ui.available_height() - refreshing_height + } else { + ui.available_height() + }; + + // Allocate space for backend message + let backend_message_height = 40.0; + if let Some((_, _, _)) = self.backend_message.clone() { + max_scroll_height -= backend_message_height; + } + + egui::ScrollArea::vertical() + .max_height(max_scroll_height) + .show(ui, |ui| { + Frame::group(ui.style()) + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.inactive.bg_stroke.color, + )) + .show(ui, |ui| { + // 2) Choose identity & key + // We'll show a dropdown of local QualifiedIdentities, then a sub-dropdown of keys + + // Identity selection + ui.add_space(10.0); + let all_identities = match self.app_context.load_local_qualified_identities() { + Ok(ids) => ids, + Err(_) => { + ui.colored_label(egui::Color32::RED, "Error loading identities from local DB"); + return; + } + }; + if all_identities.is_empty() { + ui.colored_label( + Color32::DARK_RED, + "No identities loaded. Please load or create one to register the token contract with first.", + ); + return; + } + + ui.heading("1. Select an identity and key to register the token contract with:"); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Identity:"); + egui::ComboBox::from_id_salt("token_creator_identity_selector") + .selected_text( + self.selected_identity + .as_ref() + .map(|qi| { + qi.alias + .clone() + .unwrap_or_else(|| qi.identity.id().to_string(Encoding::Base58)) + }) + .unwrap_or_else(|| "Select Identity".to_owned()), + ) + .show_ui(ui, |ui| { + for identity in all_identities.iter() { + let display = identity + .alias + .clone() + .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)); + if ui + .selectable_label( + Some(identity) == self.selected_identity.as_ref(), + display, + ) + .clicked() + { + // On select, store it + self.selected_identity = Some(identity.clone()); + // Clear the selected key & wallet + self.selected_key = None; + self.selected_wallet = None; + self.token_creator_error_message = None; + } + } + }); + }); + + // Key selection + ui.add_space(3.0); + if let Some(ref qid) = self.selected_identity { + // Attempt to list available keys (only auth keys in normal mode) + let keys = if self.app_context.developer_mode { + qid.identity + .public_keys() + .values() + .cloned() + .collect::>() + } else { + qid.available_authentication_keys() + .into_iter() + .filter_map(|k| { + if k.identity_public_key.security_level() == SecurityLevel::CRITICAL + || k.identity_public_key.security_level() == SecurityLevel::HIGH + { + Some(k.identity_public_key.clone()) + } else { + None + } + }) + .collect() + }; + + ui.horizontal(|ui| { + ui.label("Key:"); + egui::ComboBox::from_id_salt("token_creator_key_selector") + .selected_text(match &self.selected_key { + Some(k) => format!( + "Key {} (Purpose: {:?}, Security Level: {:?})", + k.id(), + k.purpose(), + k.security_level() + ), + None => "Select Key".to_owned(), + }) + .show_ui(ui, |ui| { + for k in keys { + let label = format!( + "Key {} (Purpose: {:?}, Security Level: {:?})", + k.id(), + k.purpose(), + k.security_level() + ); + if ui + .selectable_label( + Some(k.id()) == self.selected_key.as_ref().map(|kk| kk.id()), + label, + ) + .clicked() + { + self.selected_key = Some(k.clone()); + + // If the key belongs to a wallet, set that wallet reference: + self.selected_wallet = crate::ui::identities::get_selected_wallet( + qid, + None, + Some(&k), + &mut self.token_creator_error_message, + ); + } + } + }); + }); + } else { + ui.horizontal(|ui| { + ui.label("Key:"); + egui::ComboBox::from_id_salt("token_creator_key_selector_empty") + .selected_text("Select Identity First") + .show_ui(ui, |_| { + }); + }); + } + + if self.selected_key.is_none() { + return; + } + + ui.add_space(10.0); + ui.separator(); + + // 3) If the wallet is locked, show unlock + // But only do this step if we actually have a wallet reference: + let mut need_unlock = false; + let mut just_unlocked = false; + + if let Some(_) = self.selected_wallet { + let (n, j) = self.render_wallet_unlock_if_needed(ui); + need_unlock = n; + just_unlocked = j; + } + + if need_unlock && !just_unlocked { + // We must wait for unlock before continuing + return; + } + + // 4) Show input fields for token name, decimals, base supply, etc. + ui.add_space(10.0); + ui.heading("2. Enter basic token info:"); + ui.add_space(5.0); + + // Use `Grid` to align labels and text edits + egui::Grid::new("basic_token_info_grid") + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { + // Row 1: Token Name + ui.label("Token Name (singular):"); + ui.text_edit_singleline(&mut self.token_name_input); + ui.end_row(); + + // Row 2: Base Supply + ui.label("Base Supply:"); + ui.text_edit_singleline(&mut self.base_supply_input); + ui.end_row(); + + // Row 3: Max Supply + ui.label("Max Supply (optional):"); + ui.text_edit_singleline(&mut self.max_supply_input); + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // 5) Advanced settings toggle + ui.collapsing("Advanced", |ui| { + ui.add_space(3.0); + + // Use `Grid` to align labels and text edits + egui::Grid::new("advanced_token_info_grid") + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { + + // Start as paused + ui.checkbox(&mut self.start_as_paused_input, "Start as paused"); + ui.end_row(); + + // 1) Keep history? + ui.checkbox(&mut self.token_keeps_history, "Keep history"); + ui.end_row(); + + // Name should be capitalized + ui.checkbox( + &mut self.should_capitalize_input, + "Name should be capitalized", + ); + ui.end_row(); + + // Decimals + ui.horizontal(|ui| { + ui.label("Decimals:"); + ui.text_edit_singleline(&mut self.decimals_input); + }); + ui.end_row(); + }); + }); + + ui.add_space(5.0); + + ui.collapsing("Action Rules", |ui| { + ui.add_space(3.0); + + self.manual_minting_rules.render_control_change_rules_ui(ui, "Manual Mint"); + self.manual_burning_rules.render_control_change_rules_ui(ui, "Manual Burn"); + self.freeze_rules.render_control_change_rules_ui(ui, "Freeze"); + self.unfreeze_rules.render_control_change_rules_ui(ui, "Unfreeze"); + self.destroy_frozen_funds_rules.render_control_change_rules_ui(ui, "Destroy Frozen Funds"); + self.emergency_action_rules.render_control_change_rules_ui(ui, "Emergency Action"); + self.max_supply_change_rules.render_control_change_rules_ui(ui, "Max Supply Change"); + self.conventions_change_rules.render_control_change_rules_ui(ui, "Conventions Change"); + + // Main control group change is slightly different so do this one manually. + ui.collapsing("Main Control Group Change", |ui| { + ui.add_space(3.0); + + // A) authorized_to_make_change + ui.horizontal(|ui| { + ui.label("Allow main control group change:"); + egui::ComboBox::from_id_salt("main_control_group_change_selector") + .selected_text(format!( + "{}", + self.authorized_main_control_group_change.to_string() + )) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.authorized_main_control_group_change, + AuthorizedActionTakers::NoOne, + "No One", + ); + ui.selectable_value( + &mut self.authorized_main_control_group_change, + AuthorizedActionTakers::ContractOwner, + "Contract Owner", + ); + ui.selectable_value( + &mut self.authorized_main_control_group_change, + AuthorizedActionTakers::Identity(Identifier::default()), + "Identity", + ); + ui.selectable_value( + &mut self.authorized_main_control_group_change, + AuthorizedActionTakers::MainGroup, + "Main Group", + ); + ui.selectable_value( + &mut self.authorized_main_control_group_change, + AuthorizedActionTakers::Group(0), + "Group", + ); + }); + match &mut self.authorized_main_control_group_change { + AuthorizedActionTakers::Identity(_) => { + if self.main_control_group_change_authorized_identity.is_none() { + self.main_control_group_change_authorized_identity = Some(String::new()); + } + if let Some(ref mut id) = self.main_control_group_change_authorized_identity { + ui.add(egui::TextEdit::singleline(id).hint_text("base58 id")); + } + } + AuthorizedActionTakers::Group(_) => { + if self.main_control_group_change_authorized_group.is_none() { + self.main_control_group_change_authorized_group = Some("0".to_string()); + } + if let Some(ref mut group) = self.main_control_group_change_authorized_group { + ui.add(egui::TextEdit::singleline(group).hint_text("group contract position")); + } + } + _ => {} + } + }); + }); + }); + + ui.add_space(5.0); + + ui.collapsing("Distribution", |ui| { + ui.add_space(3.0); + + // PERPETUAL DISTRIBUTION SETTINGS + if ui.checkbox( + &mut self.enable_perpetual_distribution, + "Enable Perpetual Distribution", + ).clicked() { + self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::BlockBased; + self.enable_pre_programmed_distribution = false; + self.pre_programmed_distributions = Vec::new(); + }; + if self.enable_perpetual_distribution { + ui.add_space(5.0); + + // 2) Select the distribution type + ui.horizontal(|ui| { + ui.label(" Type:"); + egui::ComboBox::from_id_salt("perpetual_dist_type_selector") + .selected_text(format!("{:?}", self.perpetual_dist_type)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.perpetual_dist_type, + PerpetualDistributionIntervalTypeUI::BlockBased, + "Block-Based", + ); + ui.selectable_value( + &mut self.perpetual_dist_type, + PerpetualDistributionIntervalTypeUI::TimeBased, + "Time-Based", + ); + ui.selectable_value( + &mut self.perpetual_dist_type, + PerpetualDistributionIntervalTypeUI::EpochBased, + "Epoch-Based", + ); + }); + }); + + // If user picked a real distribution type: + match self.perpetual_dist_type { + PerpetualDistributionIntervalTypeUI::BlockBased => { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(" - Block Interval:"); + ui.text_edit_singleline(&mut self.perpetual_dist_interval_input); + }); + } + PerpetualDistributionIntervalTypeUI::TimeBased => { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(" - Time Interval (ms):"); + ui.text_edit_singleline(&mut self.perpetual_dist_interval_input); + }); + } + PerpetualDistributionIntervalTypeUI::EpochBased => { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(" - Epoch Interval:"); + ui.text_edit_singleline(&mut self.perpetual_dist_interval_input); + }); + } + PerpetualDistributionIntervalTypeUI::None => { + // Do nothing + } + } + + ui.add_space(10.0); + + // 3) Select the distribution function + ui.horizontal(|ui| { + ui.label(" Function:"); + egui::ComboBox::from_id_salt("perpetual_dist_function_selector") + .selected_text(format!("{:?}", self.perpetual_dist_function)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::FixedAmount, + "FixedAmount", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::StepDecreasingAmount, + "StepDecreasing", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::LinearInteger, + "LinearInteger", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::LinearFloat, + "LinearFloat", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::PolynomialInteger, + "PolynomialInteger", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::PolynomialFloat, + "PolynomialFloat", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::Exponential, + "Exponential", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::Logarithmic, + "Logarithmic", + ); + ui.selectable_value( + &mut self.perpetual_dist_function, + DistributionFunctionUI::Stepwise, + "Stepwise", + ); + }); + + let info_icon = egui::Label::new("ℹ").sense(egui::Sense::click()); + let response = ui.add(info_icon).on_hover_text("Info about distribution types"); + + // Check if the label was clicked + if response.clicked() { + self.show_pop_up_info = Some(r#" +### FixedAmount + +A fixed amount of tokens is emitted for each period. + +- **Formula:** f(x) = n +- **Use Case:** Simplicity, stable reward emissions +- **Example:** If we emit 5 tokens per block, and 3 blocks have passed, 15 tokens have been released. + +--- + +### StepDecreasingAmount + +The amount of tokens decreases in predefined steps at fixed intervals. + +- **Formula:** f(x) = n * (1 - decrease_per_interval)^(x / step_count) +- **Use Case:** Mimics Bitcoin/Dash models, encourages early participation +- **Example:** Bitcoin halves every 210,000 blocks (~4 years) + +--- + +### LinearInteger + +A linear function using integer precision. + +- **Formula:** f(x) = a * x + b +- **Description:** + - a > 0 -> tokens increase over time + - a < 0 -> tokens decrease over time + - b is the initial value +- **Use Case:** Incentivize early or match ecosystem growth +- **Example:** f(x) = 10x + 50 + +--- + +### LinearFloat + +A linear function with fractional (floating-point) rates. + +- **Formula:** f(x) = a * x + b +- **Description:** Similar to LinearInteger, but with fractional slope +- **Use Case:** Gradual fractional increases/decreases over time +- **Example:** f(x) = 0.5x + 50 + +--- + +### PolynomialInteger + +A polynomial function (e.g. quadratic, cubic) using integer precision. + +- **Formula:** f(x) = a * x^n + b +- **Description:** Flexible curves (growth/decay) beyond simple linear. +- **Use Case:** Diminishing or accelerating returns as time progresses +- **Example:** f(x) = 2x^2 + 20 + +--- + +### PolynomialFloat + +A polynomial function supporting fractional exponents or coefficients. + +- **Formula:** f(x) = a * x^n + b +- **Description:** Similar to PolynomialInteger, but with floats +- **Example:** f(x) = 0.5x^3 + 20 + +--- + +### Exponential + +Exponential growth or decay of tokens. + +- **Formula:** f(x) = a * e^(b * x) + c +- **Description:** + - b > 0 -> rapid growth + - b < 0 -> rapid decay +- **Use Case:** Early contributor boosts or quick emission tapering +- **Example:** f(x) = 100 * e^(-0.693 * x) + 5 + +--- + +### Logarithmic + +Logarithmic growth of token emissions. + +- **Formula:** f(x) = a * log_b(x) + c +- **Description:** Growth slows as x increases. +- **Use Case:** Sustainable long-term emission tapering +- **Example:** f(x) = 20 * log_2(x) + 5 + +--- + +### Stepwise + +Emits tokens in fixed amounts for specific intervals. + +- **Description:** Emissions remain constant within each step. +- **Use Case:** Adjust rewards at specific milestones +- **Example:** 100 tokens per block for first 1000 blocks, then 50 tokens thereafter. +"# + .to_string())}; + }); + + ui.add_space(2.0); + + // Based on the user’s chosen function, display relevant fields: + match self.perpetual_dist_function { + DistributionFunctionUI::FixedAmount => { + ui.horizontal(|ui| { + ui.label(" - Fixed Amount per Interval:"); + ui.text_edit_singleline(&mut self.fixed_amount_input); + }); + } + + DistributionFunctionUI::StepDecreasingAmount => { + ui.horizontal(|ui| { + ui.label(" - Step Count (u64):"); + ui.text_edit_singleline(&mut self.step_count_input); + }); + ui.horizontal(|ui| { + ui.label(" - Decrease per Interval (float):"); + ui.text_edit_singleline(&mut self.decrease_per_interval_input); + }); + ui.horizontal(|ui| { + ui.label(" - Initial Amount (n):"); + ui.text_edit_singleline(&mut self.step_dec_amount_input); + }); + } + + DistributionFunctionUI::LinearInteger => { + ui.horizontal(|ui| { + ui.label(" - Coefficient (a, i64):"); + ui.text_edit_singleline(&mut self.linear_int_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Initial Value (b, i64):"); + ui.text_edit_singleline(&mut self.linear_int_b_input); + }); + } + + DistributionFunctionUI::LinearFloat => { + ui.horizontal(|ui| { + ui.label(" - Coefficient (a, float):"); + ui.text_edit_singleline(&mut self.linear_float_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Initial Value (b):"); + ui.text_edit_singleline(&mut self.linear_float_b_input); + }); + } + + DistributionFunctionUI::PolynomialInteger => { + ui.horizontal(|ui| { + ui.label(" - Coefficient (a, i64):"); + ui.text_edit_singleline(&mut self.poly_int_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Degree (n, i64):"); + ui.text_edit_singleline(&mut self.poly_int_n_input); + }); + ui.horizontal(|ui| { + ui.label(" - Base Amount (b, i64):"); + ui.text_edit_singleline(&mut self.poly_int_b_input); + }); + } + + DistributionFunctionUI::PolynomialFloat => { + ui.horizontal(|ui| { + ui.label(" - Coefficient (a, float):"); + ui.text_edit_singleline(&mut self.poly_float_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Degree (n, float):"); + ui.text_edit_singleline(&mut self.poly_float_n_input); + }); + ui.horizontal(|ui| { + ui.label(" - Base Amount (b):"); + ui.text_edit_singleline(&mut self.poly_float_b_input); + }); + } + + DistributionFunctionUI::Exponential => { + ui.horizontal(|ui| { + ui.label(" - Scaling Factor (a, float):"); + ui.text_edit_singleline(&mut self.exp_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Growth/Decay Rate (b, float):"); + ui.text_edit_singleline(&mut self.exp_b_input); + }); + ui.horizontal(|ui| { + ui.label(" - Offset (c):"); + ui.text_edit_singleline(&mut self.exp_c_input); + }); + } + + DistributionFunctionUI::Logarithmic => { + ui.horizontal(|ui| { + ui.label(" - Scaling Factor (a, float):"); + ui.text_edit_singleline(&mut self.log_a_input); + }); + ui.horizontal(|ui| { + ui.label(" - Log Base (b, float):"); + ui.text_edit_singleline(&mut self.log_b_input); + }); + ui.horizontal(|ui| { + ui.label(" - Offset (c):"); + ui.text_edit_singleline(&mut self.log_c_input); + }); + } + + DistributionFunctionUI::Stepwise => { + // Example: multiple steps (u64 block => some token amount). + // Each element in `stepwise_steps` is (String, String) = (block, amount). + // You can show them in a loop and let users edit each pair. + let mut i = 0; + while i < self.stepwise_steps.len() { + let (mut block_str, mut amount_str) = self.stepwise_steps[i].clone(); + + ui.horizontal(|ui| { + ui.label(format!(" - Step #{}:", i)); + ui.label("Interval (u64):"); + ui.text_edit_singleline(&mut block_str); + + ui.label("Amount (n):"); + ui.text_edit_singleline(&mut amount_str); + + // If remove is clicked, remove the step at index i + // and *do not* increment i, because the next element + // now “shifts” into this index. + if ui.button("Remove").clicked() { + self.stepwise_steps.remove(i); + } else { + // Otherwise, update the vector with any edits and move to the next step + self.stepwise_steps[i] = (block_str, amount_str); + i += 1; + } + }); + } + + // A button to add new steps + ui.horizontal(|ui| { + ui.label(" "); + if ui.button("Add Step").clicked() { + self.stepwise_steps.push(("0".to_owned(), "0".to_owned())); + } + }); + } + } + + ui.add_space(10.0); + + // 4) Choose the distribution recipient + ui.horizontal(|ui| { + ui.label(" Recipient:"); + egui::ComboBox::from_id_salt("perpetual_dist_recipient_selector") + .selected_text(format!("{:?}", self.perpetual_dist_recipient)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.perpetual_dist_recipient, + TokenDistributionRecipientUI::ContractOwner, + "Contract Owner", + ); + ui.selectable_value( + &mut self.perpetual_dist_recipient, + TokenDistributionRecipientUI::Identity, + "Specific Identity", + ); + ui.selectable_value( + &mut self.perpetual_dist_recipient, + TokenDistributionRecipientUI::EvonodesByParticipation, + "Evonodes", + ); + }); + + // If user selected Identity or Group, show extra text edit + match &mut self.perpetual_dist_recipient { + TokenDistributionRecipientUI::Identity => { + if self.perpetual_dist_recipient_identity_input.is_none() { + self.perpetual_dist_recipient_identity_input = Some(String::new()); + } + if let Some(ref mut id) = self.perpetual_dist_recipient_identity_input { + ui.add(egui::TextEdit::singleline(id).hint_text("Enter base58 id")); + } + } + _ => {} + } + }); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label(" "); + self.perpetual_distribution_rules.render_control_change_rules_ui(ui, "Perpetual Distribution Rules"); + }); + + ui.add_space(5.0); + } else { + self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::None; + } + + ui.separator(); + + // PRE-PROGRAMMED DISTRIBUTION + if ui + .checkbox( + &mut self.enable_pre_programmed_distribution, + "Enable Pre-Programmed Distribution", + ) + .clicked() + { + // If user turns this on, you could clear the other distribution type + self.enable_perpetual_distribution = false; + self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::None; + }; + + if self.enable_pre_programmed_distribution { + ui.add_space(2.0); + + let mut i = 0; + while i < self.pre_programmed_distributions.len() { + // Clone the current entry + let mut entry = self.pre_programmed_distributions[i].clone(); + + // Render row + ui.horizontal(|ui| { + ui.label(format!(" Timestamp #{}:", i + 1)); + ui.text_edit_singleline(&mut entry.timestamp_str); + + ui.label("Identity:"); + ui.text_edit_singleline(&mut entry.identity_str); + + ui.label("Amount:"); + ui.text_edit_singleline(&mut entry.amount_str); + + if ui.button("Remove").clicked() { + self.pre_programmed_distributions.remove(i); + } else { + self.pre_programmed_distributions[i] = entry; + } + }); + + i += 1; + } + + ui.add_space(2.0); + + // Add a button to insert a blank row + ui.horizontal(|ui| { + ui.label(" "); + if ui.button("Add New Distribution Entry").clicked() { + self.pre_programmed_distributions + .push(DistributionEntry::default()); + } + }); + + ui.add_space(2.0); + } + + ui.separator(); + + // NEW TOKENS DESTINATION IDENTITY + ui.checkbox( + &mut self.new_tokens_destination_identity_enabled, + "Use a default identity to receive newly minted tokens", + ); + if self.new_tokens_destination_identity_enabled { + ui.add_space(2.0); + + // Show text field for ID + ui.horizontal(|ui| { + ui.label(" Default Destination Identity (Base58):"); + ui.text_edit_singleline(&mut self.new_tokens_destination_identity); + }); + + ui.horizontal(|ui| { + ui.label(" "); + self.new_tokens_destination_identity_rules.render_control_change_rules_ui(ui, "New Tokens Destination Identity Rules"); + }); + } + + ui.separator(); + + // MINTING ALLOW CHOOSING DESTINATION + ui.checkbox( + &mut self.minting_allow_choosing_destination, + "Allow user to pick a destination identity on each mint", + ); + if self.minting_allow_choosing_destination { + ui.horizontal(|ui| { + ui.label(" "); + self.minting_allow_choosing_destination_rules.render_control_change_rules_ui(ui, "Minting Allow Choosing Destination Rules"); + }); + } + }); + + ui.add_space(5.0); + + ui.collapsing("Groups", |ui| { + ui.add_space(3.0); + ui.label("Define one or more groups for multi-party control of the contract."); + ui.add_space(2.0); + + // Add main group selection input + ui.horizontal(|ui| { + ui.label("Main Control Group Position:"); + ui.text_edit_singleline(&mut self.main_control_group_input); + }); + + ui.add_space(2.0); + + // Draw each group in a loop + let mut i = 0; + while i < self.groups_ui.len() { + // We’ll clone it so we can safely mutate it + let mut group_ui = self.groups_ui[i].clone(); + + // Create a collapsible for the group: “Group #i” + // or use the group_position_str to label it + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + format!("group_header_{}", i).into(), + true, + ) + .show_header(ui, |ui| { + ui.label(format!("Group {}", group_ui.group_position_str)); + }) + .body(|ui| { + ui.add_space(3.0); + + ui.horizontal(|ui| { + ui.label("Group Position (u16):"); + ui.text_edit_singleline(&mut group_ui.group_position_str); + }); + + ui.horizontal(|ui| { + ui.label("Required Power:"); + ui.text_edit_singleline(&mut group_ui.required_power_str); + }); + + ui.label("Members:"); + ui.add_space(3.0); + + let mut j = 0; + while j < group_ui.members.len() { + let mut member = group_ui.members[j].clone(); + + ui.horizontal(|ui| { + ui.label(format!("Member {}:", j + 1)); + + ui.label("Identity (base58):"); + ui.text_edit_singleline(&mut member.identity_str); + + ui.label("Power (u32):"); + ui.text_edit_singleline(&mut member.power_str); + + if ui.button("Remove Member").clicked() { + group_ui.members.remove(j); + return; // return so we skip the assignment at the end + } else { + // Only assign back if we didn’t remove + group_ui.members[j] = member; + } + }); + + j += 1; + } + + ui.add_space(3.0); + if ui.button("Add Member").clicked() { + group_ui.members.push(GroupMemberUI { + identity_str: "".to_owned(), + power_str: "1".to_owned(), + }); + } + + ui.add_space(3.0); + + // A remove button for the entire group + if ui.button("Remove This Group").clicked() { + self.groups_ui.remove(i); + return; + } else { + self.groups_ui[i] = group_ui; + } + }); + + i += 1; + } + + ui.add_space(5.0); + if ui.button("Add New Group").clicked() { + self.groups_ui.push(GroupConfigUI { + group_position_str: (self.groups_ui.len() as u16).to_string(), + required_power_str: "1".to_owned(), + members: vec![], + }); + } + }); + + // 6) "Register Token Contract" button + ui.add_space(10.0); + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + ui.horizontal(|ui| { + let register_button = + egui::Button::new(RichText::new("Register Token Contract").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .rounding(3.0); + if ui.add(register_button).clicked() { + match self.parse_token_build_args() { + Ok(args) => { + // If success, show the "confirmation popup" + // Or skip the popup entirely and dispatch tasks right now + self.cached_build_args = Some(args); + self.token_creator_error_message = None; + self.show_token_creator_confirmation_popup = true; + }, + Err(err) => { + self.token_creator_error_message = Some(err); + } + } + } + let view_json_button = egui::Button::new(RichText::new("View JSON").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .rounding(3.0); + if ui.add(view_json_button).clicked() { + match self.parse_token_build_args() { + Ok(args) => { + // We have the parsed token creation arguments + // We can now call build_data_contract_v1_with_one_token using `args` + self.cached_build_args = Some(args.clone()); + let data_contract = match self.app_context.build_data_contract_v1_with_one_token( + args.identity_id, + args.token_name, + args.should_capitalize, + args.decimals, + args.base_supply, + args.max_supply, + args.start_paused, + args.keeps_history, + args.main_control_group, + args.manual_minting_rules, + args.manual_burning_rules, + args.freeze_rules, + args.unfreeze_rules, + args.destroy_frozen_funds_rules, + args.emergency_action_rules, + args.max_supply_change_rules, + args.conventions_change_rules, + args.main_control_group_change_authorized, + args.distribution_rules, + args.groups, + ) { + Ok(dc) => dc, + Err(e) => { + self.token_creator_error_message = Some(format!("Error building contract V1: {e}")); + return; + } + }; + + let data_contract_json = data_contract.to_json(self.app_context.platform_version).expect("Expected to map contract to json"); + self.show_json_popup = true; + self.json_popup_text = serde_json::to_string_pretty(&data_contract_json).expect("Expected to serialize json"); + }, + Err(err_msg) => { + self.token_creator_error_message = Some(err_msg); + }, + } + } + }); + }); + }); + + // 7) If the user pressed "Register Token Contract," show a popup confirmation + if self.show_token_creator_confirmation_popup { + action |= self.render_token_creator_confirmation_popup(ui); + } + + if self.show_json_popup { + self.render_data_contract_json_popup(ui); + } + + // 8) If we are waiting, show spinner / time elapsed + if let TokenCreatorStatus::WaitingForResult(start_time) = self.token_creator_status { + let now = chrono::Utc::now().timestamp() as u64; + let elapsed = now - start_time; + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label(format!( + "Registering token contract... elapsed {}s", + elapsed + )); + ui.add(egui::widgets::Spinner::default()); + }); + } + + // Show an error if we have one + if let Some(err_msg) = &self.token_creator_error_message { + ui.add_space(10.0); + ui.colored_label(egui::Color32::RED, format!("{err_msg}")); + ui.add_space(10.0); + } + + action + } + + /// Renders a popup window displaying the data contract JSON. + pub fn render_data_contract_json_popup(&mut self, ui: &mut egui::Ui) { + if self.show_json_popup { + let mut is_open = true; + egui::Window::new("Data Contract JSON") + .collapsible(false) + .resizable(true) + .max_height(600.0) + .max_width(800.0) + .scroll(true) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Display the JSON in a multiline text box + ui.add_space(4.0); + ui.label("Below is the data contract JSON:"); + ui.add_space(4.0); + + egui::Resize::default() + .id_salt("json_resize_area_for_contract") + .default_size([750.0, 550.0]) + .max_height(ui.available_height() - 50.0) + .max_width(ui.available_height() - 20.0) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.monospace(&mut self.json_popup_text); + }); + }); + + ui.add_space(10.0); + + // A button to close + if ui.button("Close").clicked() { + self.show_json_popup = false; + } + }); + + // If the user closed the window via the "x" in the corner + // we should reflect that in `show_json_popup`. + if !is_open { + self.show_json_popup = false; + } + } + } + + /// Shows a popup "Are you sure?" for creating the token contract + fn render_token_creator_confirmation_popup(&mut self, ui: &mut egui::Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + + egui::Window::new("Confirm Token Contract Registration") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + ui.label( + "Are you sure you want to register a new token contract with these settings?\n", + ); + let max_supply_display = if self.max_supply_input.len() == 0 { + "None".to_string() + } else { + self.max_supply_input.clone() + }; + ui.label(format!( + "Name: {}\nBase Supply: {}\nMax Supply: {}", + self.token_name_input, + self.base_supply_input, + max_supply_display, + )); + + ui.add_space(10.0); + + // Confirm + if ui.button("Confirm").clicked() { + let args = match &self.cached_build_args { + Some(args) => args.clone(), + None => { + // fallback if we didn't store them + match self.parse_token_build_args() { + Ok(a) => a, + Err(err) => { + self.token_creator_error_message = Some(err); + self.show_token_creator_confirmation_popup = false; + action = AppAction::None; + return; + } + } + } + }; + + // Now create your tasks + let tasks = vec![ + BackendTask::TokenTask(TokenTask::RegisterTokenContract { + identity: self.selected_identity.clone().unwrap(), + signing_key: self.selected_key.clone().unwrap(), + + token_name: args.token_name, + should_capitalize: args.should_capitalize, + decimals: args.decimals, + base_supply: args.base_supply, + max_supply: args.max_supply, + start_paused: args.start_paused, + keeps_history: args.keeps_history, + main_control_group: args.main_control_group, + + manual_minting_rules: args.manual_minting_rules, + manual_burning_rules: args.manual_burning_rules, + freeze_rules: args.freeze_rules, + unfreeze_rules: args.unfreeze_rules, + destroy_frozen_funds_rules: args.destroy_frozen_funds_rules, + emergency_action_rules: args.emergency_action_rules, + max_supply_change_rules: args.max_supply_change_rules, + conventions_change_rules: args.conventions_change_rules, + main_control_group_change_authorized: args.main_control_group_change_authorized, + distribution_rules: args.distribution_rules, + groups: args.groups, + }), + BackendTask::TokenTask(TokenTask::QueryMyTokenBalances), + ]; + + action = AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Sequential); + } + + // Cancel + if ui.button("Cancel").clicked() { + self.show_token_creator_confirmation_popup = false; + action = AppAction::None; + } + }); + + if !is_open { + self.show_token_creator_confirmation_popup = false; + } + + action + } + + /// Once the contract creation is done (status=Complete), + /// render a simple "Success" screen + fn render_token_creator_success_screen(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.heading("Token Contract Created Successfully! 🎉"); + ui.add_space(10.0); + if ui.button("Back").clicked() { + self.reset_token_creator(); + } + }); + } + + /// Gathers user input and produces the arguments needed by + /// `build_data_contract_v1_with_one_token`. + /// Returns Err(error_msg) on invalid input. + pub fn parse_token_build_args(&mut self) -> Result { + // 1) We must have a selected identity + let identity = self.selected_identity.clone() + .ok_or_else(|| "Please select an identity".to_string())?; + let identity_id = identity.identity.id().clone(); + + // 2) Basic fields + if self.token_name_input.is_empty() { + return Err("Please enter a token name".to_string()); + } + let token_name = self.token_name_input.clone(); + + let decimals = self.decimals_input.parse::().unwrap_or(8); + let base_supply = self.base_supply_input.parse::().unwrap_or(1000000); + let max_supply = if self.max_supply_input.is_empty() { + None + } else { + // If parse fails, error out + Some(self.max_supply_input.parse::() + .map_err(|_| "Invalid Max Supply".to_string())?) + }; + + let start_paused = self.start_as_paused_input; + let keeps_history = self.token_keeps_history; + + let main_control_group = if self.main_control_group_input.is_empty() { + None + } else { + Some(self.main_control_group_input.parse::() + .map_err(|_| "Invalid main control group".to_string())?) + }; + + // 3) Convert your ActionChangeControlUI fields to real rules + // (or do the manual parse for each if needed) + let manual_minting_rules = self.manual_minting_rules + .to_change_control_rules("Manual Mint")?; + let manual_burning_rules = self.manual_burning_rules + .to_change_control_rules("Manual Burn")?; + let freeze_rules = self.freeze_rules + .to_change_control_rules("Freeze")?; + let unfreeze_rules = self.unfreeze_rules + .to_change_control_rules("Unfreeze")?; + let destroy_frozen_funds_rules = self.destroy_frozen_funds_rules + .to_change_control_rules("Destroy Frozen Funds")?; + let emergency_action_rules = self.emergency_action_rules + .to_change_control_rules("Emergency Action")?; + let max_supply_change_rules = self.max_supply_change_rules + .to_change_control_rules("Max Supply Change")?; + let conventions_change_rules = self.conventions_change_rules + .to_change_control_rules("Conventions Change")?; + + // The main_control_group_change_authorized is done manually in your code, + // parse identity or group if needed. Reuse your existing logic: + let main_control_group_change_authorized = + self.parse_main_control_group_change_authorized()?; + + // 4) Distribution data (perpetual & pre_programmed) + let distribution_rules = self.build_distribution_rules()?; + + // 5) Groups + let groups = self.parse_groups()?; + + // 6) Put it all in a struct + Ok(TokenBuildArgs { + identity_id, + token_name, + should_capitalize: self.should_capitalize_input, + decimals, + base_supply, + max_supply, + start_paused, + keeps_history, + main_control_group, + + manual_minting_rules, + manual_burning_rules, + freeze_rules, + unfreeze_rules, + destroy_frozen_funds_rules, + emergency_action_rules, + max_supply_change_rules, + conventions_change_rules, + main_control_group_change_authorized, + + distribution_rules: TokenDistributionRules::V0(distribution_rules), + groups, + }) + } + + /// Example of pulling out the logic to parse main_control_group_change_authorized + fn parse_main_control_group_change_authorized( + &mut self + ) -> Result { + match &mut self.authorized_main_control_group_change { + AuthorizedActionTakers::Identity(_) => { + if let Some(ref id_str) = self.main_control_group_change_authorized_identity { + if let Ok(id) = Identifier::from_string(id_str, Encoding::Base58) { + Ok(AuthorizedActionTakers::Identity(id)) + } else { + Err("Invalid base58 identifier for main control group change authorized identity".to_owned()) + } + } else { + Ok(AuthorizedActionTakers::Identity(Identifier::default())) + } + } + AuthorizedActionTakers::Group(_) => { + if let Some(ref group_str) = self.main_control_group_change_authorized_group { + if let Ok(g) = group_str.parse::() { + Ok(AuthorizedActionTakers::Group(g)) + } else { + Err("Invalid group contract position for main control group".to_owned()) + } + } else { + Ok(AuthorizedActionTakers::Group(0)) + } + } + other => { + // For ContractOwner or NoOne, just return them as-is + Ok(other.clone()) + } + } + } + + fn build_distribution_rules(&mut self) -> Result { + // 1) Validate distribution input, parse numeric fields, etc. + let distribution_function = match self.perpetual_dist_function { + DistributionFunctionUI::FixedAmount => { + DistributionFunction::FixedAmount { + n: self.fixed_amount_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::StepDecreasingAmount => { + DistributionFunction::StepDecreasingAmount { + n: self.step_dec_amount_input.parse::().unwrap_or(0), + decrease_per_interval: NotNan::new(self.decrease_per_interval_input.parse::().unwrap_or(0.0)).unwrap(), + step_count: self.step_count_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::LinearInteger => { + DistributionFunction::LinearInteger { + a: self.linear_int_a_input.parse::().unwrap_or(0), + b: self.linear_int_b_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::LinearFloat => { + DistributionFunction::LinearFloat { + a: NotNan::new(self.linear_float_a_input.parse::().unwrap_or(0.0)).unwrap(), + b: self.linear_float_b_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::PolynomialInteger => { + DistributionFunction::PolynomialInteger { + a: self.poly_int_a_input.parse::().unwrap_or(0), + n: self.poly_int_n_input.parse::().unwrap_or(0), + b: self.poly_int_b_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::PolynomialFloat => { + DistributionFunction::PolynomialFloat { + a: NotNan::new(self.poly_float_a_input.parse::().unwrap_or(0.0)).unwrap(), + n: NotNan::new(self.poly_float_n_input.parse::().unwrap_or(0.0)).unwrap(), + b: self.poly_float_b_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::Exponential => { + DistributionFunction::Exponential { + a: NotNan::new(self.exp_a_input.parse::().unwrap_or(0.0)).unwrap(), + b: NotNan::new(self.exp_b_input.parse::().unwrap_or(0.0)).unwrap(), + c: self.exp_c_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::Logarithmic => { + DistributionFunction::Logarithmic { + a: NotNan::new(self.log_a_input.parse::().unwrap_or(0.0)).unwrap(), + b: NotNan::new(self.log_b_input.parse::().unwrap_or(0.0)).unwrap(), + c: self.log_c_input.parse::().unwrap_or(0), + } + }, + DistributionFunctionUI::Stepwise => { + let mut steps = Vec::new(); + for (block_str, amount_str) in self.stepwise_steps.iter() { + if let Ok(block) = block_str.parse::() { + if let Ok(amount) = amount_str.parse::() { + steps.push((block, amount)); + } else { + self.token_creator_error_message = Some( + "Invalid amount in stepwise distribution".to_string(), + ); + return Err("Invalid amount in stepwise distribution".to_string()); + } + } else { + self.token_creator_error_message = Some( + "Invalid block interval in stepwise distribution".to_string(), + ); + return Err("Invalid block interval in stepwise distribution".to_string()); + } + } + DistributionFunction::Stepwise(steps) + } + }; + let maybe_perpetual_distribution = if self.enable_perpetual_distribution { + // Construct the `TokenPerpetualDistributionV0` from your selected type + function + let dist_type = match self.perpetual_dist_type { + PerpetualDistributionIntervalTypeUI::BlockBased => { + // parse interval, parse emission + // parse distribution function + RewardDistributionType::BlockBasedDistribution( + self.perpetual_dist_interval_input.parse::().unwrap_or(0), + 0, // this field should be removed in Platform because the individual functions define it + distribution_function, + ) + } + PerpetualDistributionIntervalTypeUI::EpochBased => { + RewardDistributionType::EpochBasedDistribution( + self.perpetual_dist_interval_input.parse::().unwrap_or(0), + 0, // this field should be removed in Platform because the individual functions define it + distribution_function, + ) + } + PerpetualDistributionIntervalTypeUI::TimeBased => { + RewardDistributionType::TimeBasedDistribution( + self.perpetual_dist_interval_input.parse::().unwrap_or(0), + 0, // this field should be removed in Platform because the individual functions define it + distribution_function, + ) + } + _ => { + RewardDistributionType::BlockBasedDistribution(0, 0, DistributionFunction::FixedAmount { n: 0 }) + } + }; + + let recipient = match self.perpetual_dist_recipient { + TokenDistributionRecipientUI::ContractOwner => TokenDistributionRecipient::ContractOwner, + TokenDistributionRecipientUI::Identity => { + if let Some(id) = self.perpetual_dist_recipient_identity_input.as_ref() { + let id_res = Identifier::from_string(id, Encoding::Base58); + TokenDistributionRecipient::Identity(id_res.unwrap_or_default()) + } else { + self.token_creator_error_message = Some( + "Invalid base58 identifier for perpetual distribution recipient".to_string(), + ); + return Err("Invalid base58 identifier for perpetual distribution recipient".to_string()); + } + } + TokenDistributionRecipientUI::EvonodesByParticipation => { + TokenDistributionRecipient::EvonodesByParticipation + } + }; + + Some(TokenPerpetualDistribution::V0(TokenPerpetualDistributionV0 { + distribution_type: dist_type, + distribution_recipient: recipient, + })) + } else { + None + }; + + // 2) Build the distribution rules structure + let dist_rules_v0 = TokenDistributionRulesV0 { + perpetual_distribution: maybe_perpetual_distribution, + perpetual_distribution_rules: self.perpetual_distribution_rules.to_change_control_rules("Perpetual Distribution").unwrap(), + pre_programmed_distribution: if self.enable_pre_programmed_distribution { + let distributions: BTreeMap> = match self.parse_pre_programmed_distributions() { + Ok(distributions) => distributions.into_iter().map(|(k, v)| (k, std::iter::once(v).collect())).collect(), + Err(err) => { + self.token_creator_error_message = Some(err.clone()); + return Err(err.to_string()); + } + }; + + Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions, + } + )) + } else { + None + }, + new_tokens_destination_identity: if self.new_tokens_destination_identity_enabled { + Some(Identifier::from_string(&self.new_tokens_destination_identity, Encoding::Base58).unwrap_or_default()) + } else { + None + }, + new_tokens_destination_identity_rules: self.new_tokens_destination_identity_rules.to_change_control_rules("New Tokens Destination Identity").unwrap(), + minting_allow_choosing_destination: self.minting_allow_choosing_destination, + minting_allow_choosing_destination_rules: self.minting_allow_choosing_destination_rules.to_change_control_rules("Minting Allow Choosing Destination").unwrap(), + }; + + Ok(dist_rules_v0) + } + + /// Attempts to parse the `pre_programmed_distributions` into a BTreeMap. + /// Returns an error string if any row fails. + pub fn parse_pre_programmed_distributions( + &mut self, + ) -> Result, String> { + let mut map = BTreeMap::new(); + for (i, entry) in self.pre_programmed_distributions.iter().enumerate() { + // Parse timestamp + let timestamp = entry.timestamp_str.parse::().map_err(|_| { + format!( + "Row {}: invalid timestamp (expected u64). Got '{}'", + i + 1, + entry.timestamp_str + ) + })?; + + // Parse identity + let id = + Identifier::from_string(&entry.identity_str, Encoding::Base58).map_err(|_| { + format!( + "Row {}: invalid base58 identity '{}'", + i + 1, + entry.identity_str + ) + })?; + + // Parse amount + let amount = entry.amount_str.parse::().map_err(|_| { + format!( + "Row {}: invalid distribution amount (expected u64). Got '{}'", + i + 1, + entry.amount_str + ) + })?; + + // Insert into the map + map.insert(timestamp, (id, amount)); + } + Ok(map) + } + + /// Attempt to parse all group UI data into a BTreeMap. + /// Returns an error if any row fails or duplicates a position, etc. + pub fn parse_groups(&self) -> Result, String> { + let mut map = BTreeMap::new(); + for (i, g) in self.groups_ui.iter().enumerate() { + let (pos, group) = g + .parse_into_group() + .map_err(|e| format!("Error in Group #{}: {e}", i + 1))?; + + // Check for duplicates + if map.contains_key(&pos) { + return Err(format!( + "Duplicate group position {pos} in Group #{}", + i + 1 + )); + } + + map.insert(pos, group); + } + Ok(map) + } + + fn reset_token_creator(&mut self) { + self.selected_identity = None; + self.selected_key = None; + self.token_creator_status = TokenCreatorStatus::NotStarted; + self.token_name_input = "".to_string(); + self.decimals_input = "8".to_string(); + self.base_supply_input = "100000".to_string(); + self.max_supply_input = "".to_string(); + self.start_as_paused_input = false; + self.should_capitalize_input = false; + self.token_keeps_history = false; + self.manual_minting_rules = ChangeControlRulesUI::default(); + self.manual_burning_rules = ChangeControlRulesUI::default(); + self.freeze_rules = ChangeControlRulesUI::default(); + self.unfreeze_rules = ChangeControlRulesUI::default(); + self.destroy_frozen_funds_rules = ChangeControlRulesUI::default(); + self.emergency_action_rules = ChangeControlRulesUI::default(); + self.max_supply_change_rules = ChangeControlRulesUI::default(); + self.conventions_change_rules = ChangeControlRulesUI::default(); + self.authorized_main_control_group_change = AuthorizedActionTakers::NoOne; + self.main_control_group_change_authorized_identity = None; + self.main_control_group_change_authorized_group = None; + self.main_control_group_input = "".to_string(); + self.perpetual_dist_function = DistributionFunctionUI::FixedAmount; + self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::None; + self.perpetual_dist_interval_input = "".to_string(); + self.fixed_amount_input = "".to_string(); + self.step_dec_amount_input = "".to_string(); + self.decrease_per_interval_input = "".to_string(); + self.step_count_input = "".to_string(); + self.linear_int_a_input = "".to_string(); + self.linear_int_b_input = "".to_string(); + self.linear_float_a_input = "".to_string(); + self.linear_float_b_input = "".to_string(); + self.poly_int_a_input = "".to_string(); + self.poly_int_n_input = "".to_string(); + self.poly_int_b_input = "".to_string(); + self.poly_float_a_input = "".to_string(); + self.poly_float_n_input = "".to_string(); + self.poly_float_b_input = "".to_string(); + self.exp_a_input = "".to_string(); + self.exp_b_input = "".to_string(); + self.exp_c_input = "".to_string(); + self.log_a_input = "".to_string(); + self.log_b_input = "".to_string(); + self.log_c_input = "".to_string(); + self.stepwise_steps = vec![(String::new(), String::new())]; + self.perpetual_dist_recipient = TokenDistributionRecipientUI::ContractOwner; + self.perpetual_dist_recipient_identity_input = None; + self.enable_perpetual_distribution = false; + self.perpetual_distribution_rules = ChangeControlRulesUI::default(); + self.enable_pre_programmed_distribution = false; + self.pre_programmed_distributions = Vec::new(); + self.new_tokens_destination_identity_enabled = false; + self.new_tokens_destination_identity_rules = ChangeControlRulesUI::default(); + self.minting_allow_choosing_destination_rules = ChangeControlRulesUI::default(); + self.show_token_creator_confirmation_popup = false; + self.token_creator_error_message = None; + } + + fn render_no_owned_tokens(&mut self, ui: &mut Ui) -> AppAction { + let mut app_action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(20.0); + match self.tokens_subscreen { + TokensSubscreen::MyTokens => { + ui.label( + egui::RichText::new("No owned tokens found.") + .heading() + .strong() + .color(Color32::GRAY), + ); + } + TokensSubscreen::SearchTokens => { + ui.label( + egui::RichText::new("No matching tokens found.") + .heading() + .strong() + .color(Color32::GRAY), + ); + } + TokensSubscreen::TokenCreator => { + ui.label( + egui::RichText::new("Cannot render token creator for some reason") + .heading() + .strong() + .color(Color32::GRAY), + ); + } + } + ui.add_space(10.0); + + ui.label("Please check back later or try refreshing the list."); + ui.add_space(20.0); + if ui.button("Refresh").clicked() { + if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + app_action = AppAction::None; + } else { + let now = Utc::now().timestamp() as u64; + self.refreshing_status = RefreshingStatus::Refreshing(now); + match self.tokens_subscreen { + TokensSubscreen::MyTokens => { + app_action = AppAction::BackendTask(BackendTask::TokenTask( + TokenTask::QueryMyTokenBalances, + )); + } + TokensSubscreen::SearchTokens => { + app_action = AppAction::Refresh; + } + TokensSubscreen::TokenCreator => { + app_action = AppAction::Refresh; + } + } + } + } + }); + + app_action + } + + fn add_token_to_my_tokens(&self, token: IdentityTokenBalance) { + let mut my_tokens = self.my_tokens.lock().unwrap(); + // Prevent duplicates + if !my_tokens + .iter() + .any(|t| t.token_identifier == token.token_identifier) + { + my_tokens.push(token); + } + // Save the new order + self.save_current_order(); + } + + fn goto_next_search_page(&mut self) -> AppAction { + // If we have a next cursor: + if let Some(next_cursor) = self.next_cursors.last().cloned() { + // set status + let now = Utc::now().timestamp() as u64; + self.token_search_status = TokenSearchStatus::WaitingForResult(now); + + // push the current one onto “previous” so we can go back + // if the user is on page N, and we have a nextCursor in next_cursors[N - 1] or so + self.previous_cursors.push(next_cursor.clone()); + + self.search_current_page += 1; + + // Dispatch + let query_string = self + .token_search_query + .as_ref() + .map(|s| s.clone()) + .unwrap_or_default(); + + return AppAction::BackendTask(BackendTask::TokenTask( + TokenTask::QueryTokensByKeywordPage(query_string, Some(next_cursor)), + )); + } + AppAction::None + } + + fn goto_previous_search_page(&mut self) -> AppAction { + if self.search_current_page > 1 { + // Move to (page - 1) + self.search_current_page -= 1; + let now = Utc::now().timestamp() as u64; + self.token_search_status = TokenSearchStatus::WaitingForResult(now); + + // The “last” previous_cursors item is the new page’s state + if let Some(prev_cursor) = self.previous_cursors.pop() { + // Possibly pop from next_cursors if we want to re-insert it later + // self.next_cursors.truncate(self.search_current_page - 1); + let query_string = self + .token_search_query + .as_ref() + .map(|s| s.clone()) + .unwrap_or_default(); + return AppAction::BackendTask(BackendTask::TokenTask( + TokenTask::QueryTokensByKeywordPage(query_string, Some(prev_cursor)), + )); + } + } + AppAction::None + } + + fn show_remove_identity_token_balance_popup(&mut self, ui: &mut egui::Ui) { + // If no token is set, nothing to confirm + let token_to_remove = match &self.identity_token_balance_to_remove { + Some(token) => token.clone(), + None => { + self.confirm_remove_identity_token_balance_popup = false; + return; + } + }; + + let mut is_open = true; + + egui::Window::new("Confirm Remove Balance") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + ui.label(format!( + "Are you sure you want to remove identity token balance \"{}\" for identity \"{}\"?", + token_to_remove.token_name, + token_to_remove.identity_id.to_string(Encoding::Base58) + )); + + // Confirm button + if ui.button("Confirm").clicked() { + if let Err(e) = self.app_context.remove_token_balance( + token_to_remove.token_identifier, + token_to_remove.identity_id.clone(), + ) { + self.backend_message = Some(( + format!("Error removing token balance: {}", e), + MessageType::Error, + Utc::now(), + )); + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + } else { + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + self.refresh(); + }; + } + + // Cancel button + if ui.button("Cancel").clicked() { + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + } + }); + + // If user closes the popup window (the [x] button), also reset state + if !is_open { + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + } + } + + fn show_remove_token_popup(&mut self, ui: &mut egui::Ui) { + // If no token is set, nothing to confirm + let token_to_remove = match &self.token_to_remove { + Some(token) => token.clone(), + None => { + self.confirm_remove_token_popup = false; + return; + } + }; + + // find the token name from one of the identity token balances in my tokens + let token_name = self + .my_tokens + .lock() + .unwrap() + .iter() + .find_map(|t| { + if t.token_identifier == token_to_remove { + Some(t.token_name.clone()) + } else { + None + } + }) + .unwrap_or_else(|| token_to_remove.to_string(Encoding::Base58)); + + let mut is_open = true; + + egui::Window::new("Confirm Remove Token") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + ui.label(format!( + "Are you sure you want to remove token \"{}\" for all identities?", + token_name, + )); + + // Confirm button + if ui.button("Confirm").clicked() { + for identity in self + .app_context + .load_local_qualified_identities() + .expect("Expected to load local qualified identities") + { + if let Err(e) = self.app_context.remove_token_balance( + token_to_remove.clone(), + identity.identity.id().clone(), + ) { + self.backend_message = Some(( + format!("Error removing token balance: {}", e), + MessageType::Error, + Utc::now(), + )); + self.confirm_remove_token_popup = false; + self.token_to_remove = None; + } else { + self.confirm_remove_token_popup = false; + self.token_to_remove = None; + self.refresh(); + } + } + } + + // Cancel button + if ui.button("Cancel").clicked() { + self.confirm_remove_token_popup = false; + self.token_to_remove = None; + } + }); + + // If user closes the popup window (the [x] button), also reset state + if !is_open { + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + } + } +} + +// ───────────────────────────────────────────────────────────────── +// ScreenLike implementation +// ───────────────────────────────────────────────────────────────── +impl ScreenLike for TokensScreen { + fn refresh(&mut self) { + self.my_tokens = Arc::new(Mutex::new( + self.app_context + .identity_token_balances() + .unwrap_or_default(), + )); + match self.app_context.db.load_token_order() { + Ok(saved_ids) => { + self.reorder_vec_to(saved_ids); + self.use_custom_order = true; + } + Err(e) => { + eprintln!("Error loading token order: {}", e); + } + } + } + + fn refresh_on_arrival(&mut self) { + self.selected_token_id = None; + self.my_tokens = Arc::new(Mutex::new( + self.app_context + .identity_token_balances() + .unwrap_or_default(), + )); + } + + fn display_message(&mut self, msg: &str, msg_type: MessageType) { + if self.tokens_subscreen == TokensSubscreen::TokenCreator { + // Handle messages from Token Creator + if msg.contains("Successfully registered token contract") { + self.token_creator_status = TokenCreatorStatus::Complete; + } else if msg.contains("Error registering token contract") { + self.token_creator_status = TokenCreatorStatus::ErrorMessage(msg.to_string()); + self.token_creator_error_message = Some(msg.to_string()); + } else { + return; + } + } + + // Handle messages from querying My Token Balances + if msg.contains("Successfully fetched token balances") + | msg.contains("Failed to fetch token balances") + { + self.backend_message = Some((msg.to_string(), msg_type, Utc::now())); + self.refreshing_status = RefreshingStatus::NotRefreshing; + } + + // Handle messages from Token Search + if msg.contains("Error fetching tokens") { + self.token_search_status = TokenSearchStatus::ErrorMessage(msg.to_string()); + self.backend_message = Some((msg.to_string(), msg_type, Utc::now())); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + match backend_task_success_result { + BackendTaskSuccessResult::TokensByKeyword(tokens) => { + // This might be a “full” result (no paging). + let mut srch = self.search_results.lock().unwrap(); + *srch = tokens; + self.token_search_status = TokenSearchStatus::Complete; + } + BackendTaskSuccessResult::TokensByKeywordPage(tokens, next_cursor) => { + // Paged result + let mut srch = self.search_results.lock().unwrap(); + *srch = tokens; + self.search_has_next_page = next_cursor.is_some(); + + if let Some(cursor) = next_cursor { + // Save it for “next page” retrieval + self.next_cursors.push(cursor); + } + self.token_search_status = TokenSearchStatus::Complete; + } + _ => {} + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + + self.check_error_expiration(); + + // Build top-right buttons + let right_buttons = match self.tokens_subscreen { + TokensSubscreen::MyTokens => vec![( + "Refresh", + DesiredAppAction::BackendTask(BackendTask::TokenTask( + TokenTask::QueryMyTokenBalances, + )), + )], + TokensSubscreen::SearchTokens => vec![("Refresh", DesiredAppAction::Refresh)], + TokensSubscreen::TokenCreator => vec![], + }; + + // Top panel + if let Some(token_id) = self.selected_token_id { + let token_name: String = self + .my_tokens + .lock() + .unwrap() + .iter() + .find_map(|t| { + if t.token_identifier == token_id { + Some(t.token_name.clone()) + } else { + None + } + }) + .unwrap_or_else(|| token_id.to_string(Encoding::Base58)); + + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::Custom("Back to tokens".to_string())), + (&format!("{token_name}"), AppAction::None), + ], + right_buttons.clone(), + ); + } else { + action |= add_top_panel( + ctx, + &self.app_context, + vec![("Tokens", AppAction::None)], + right_buttons.clone(), + ); + } + + // Left panel + match self.tokens_subscreen { + TokensSubscreen::MyTokens => { + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenMyTokenBalances, + ); + } + TokensSubscreen::SearchTokens => { + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenTokenSearch, + ); + } + TokensSubscreen::TokenCreator => { + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenTokenCreator, + ); + } + } + + // Subscreen chooser + action |= add_tokens_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + + // Main panel + CentralPanel::default().show(ctx, |ui| { + match self.tokens_subscreen { + TokensSubscreen::MyTokens => { + let tokens = self.my_tokens.lock().unwrap().clone(); + if tokens.is_empty() { + // If no tokens, show a “no tokens found” message + action |= self.render_no_owned_tokens(ui); + } else { + // Are we showing details for a selected token? + if self.selected_token_id.is_some() { + // Render detail view for one token + action |= self.render_token_details(ui, &tokens); + } else { + // Otherwise, show the list of all tokens + self.render_token_list(ui, &tokens); + } + } + } + TokensSubscreen::SearchTokens => { + action |= self.render_token_search(ui); + } + TokensSubscreen::TokenCreator => { + action |= self.render_token_creator(ui); + } + } + + // If we are refreshing, show a spinner at the bottom + if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { + ui.add_space(5.0); + let now = Utc::now().timestamp() as u64; + let elapsed = now - start_time; + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.label(format!("Refreshing... Time so far: {}", elapsed)); + ui.add(egui::widgets::Spinner::default().color(Color32::from_rgb(0, 128, 255))); + }); + ui.add_space(10.0); + } + + // If there's a backend message, show it at the bottom + if let Some((msg, msg_type, timestamp)) = self.backend_message.clone() { + let color = match msg_type { + MessageType::Error => Color32::DARK_RED, + MessageType::Info => Color32::BLACK, + MessageType::Success => Color32::DARK_GREEN, + }; + ui.group(|ui| { + ui.horizontal_wrapped(|ui| { + ui.colored_label(color, &msg); + let now = Utc::now(); + let elapsed = now.signed_duration_since(timestamp); + if ui + .button(format!("Dismiss ({})", 10 - elapsed.num_seconds())) + .clicked() + { + self.dismiss_message(); + } + }); + }); + } + + if self.confirm_remove_identity_token_balance_popup { + self.show_remove_identity_token_balance_popup(ui); + } + if self.confirm_remove_token_popup { + self.show_remove_token_popup(ui); + } + + // If we have info text, open a pop-up window to show it + if let Some(info_text) = self.show_pop_up_info.clone() { + egui::Window::new("Distribution Type Info") + .collapsible(false) + .resizable(true) + .show(ui.ctx(), |ui| { + egui::ScrollArea::vertical() + .max_height(600.0) + .show(ui, |ui| { + let mut cache = CommonMarkCache::default(); + CommonMarkViewer::new().show(ui, &mut cache, &info_text); + }); + + if ui.button("Close").clicked() { + self.show_pop_up_info = None; + } + }); + } + }); + + // Post-processing on user actions + match action { + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::QueryMyTokenBalances)) => { + self.refreshing_status = + RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + } + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::QueryTokensByKeyword(_))) => { + self.refreshing_status = + RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + } + AppAction::SetMainScreen(_) => { + self.refreshing_status = RefreshingStatus::NotRefreshing; + self.selected_token_id = None; + self.reset_token_creator(); + } + AppAction::Custom(ref s) if s == "Back to tokens" => { + self.selected_token_id = None; + } + _ => {} + } + + if action == AppAction::None { + if let Some(bt) = self.pending_backend_task.take() { + action = AppAction::BackendTask(bt); + } + } + action + } +} + +impl ScreenWithWalletUnlock for TokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.token_creator_error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.token_creator_error_message.as_ref() + } +} diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs new file mode 100644 index 000000000..371227392 --- /dev/null +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -0,0 +1,517 @@ +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::prelude::TimestampMillis; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use eframe::egui::{self, Context, Ui}; +use egui::{Color32, RichText}; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; + +use crate::ui::identities::get_selected_wallet; + +use super::tokens_screen::IdentityTokenBalance; + +#[derive(PartialEq)] +pub enum TransferTokensStatus { + NotStarted, + WaitingForResult(TimestampMillis), + ErrorMessage(String), + Complete, +} + +pub struct TransferTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, + selected_key: Option, + receiver_identity_id: String, + amount: String, + transfer_tokens_status: TransferTokensStatus, + error_message: Option, + max_amount: u64, + pub app_context: Arc, + confirmation_popup: bool, + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl TransferTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + let identity = app_context + .load_local_qualified_identities() + .expect("Identities not loaded") + .iter() + .find(|identity| identity.identity.id() == identity_token_balance.identity_id) + .expect("Identity not found") + .clone(); + let token_balances = app_context + .db + .get_identity_token_balances(app_context) + .expect("Token balances not loaded"); + let max_amount = token_balances + .iter() + .find(|balance| balance.identity_id == identity.identity.id()) + .map(|balance| balance.balance) + .unwrap_or(0); + let identity_clone = identity.identity.clone(); + let selected_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, selected_key, &mut error_message); + Self { + identity, + identity_token_balance, + selected_key: selected_key.cloned(), + receiver_identity_id: String::new(), + amount: String::new(), + transfer_tokens_status: TransferTokensStatus::NotStarted, + error_message: None, + max_amount, + app_context: app_context.clone(), + confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + + egui::ComboBox::from_id_salt("key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + for key in self.identity.available_authentication_keys() { + let label = format!( + "Key ID: {} (Purpose: {:?})", + key.identity_public_key.id(), + key.identity_public_key.purpose() + ); + ui.selectable_value( + &mut self.selected_key, + Some(key.identity_public_key.clone()), + label, + ); + } + } + }); + }); + } + + fn render_amount_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Amount:"); + + ui.text_edit_singleline(&mut self.amount); + + if ui.button("Max").clicked() { + self.amount = self.max_amount.to_string(); + } + }); + } + + fn render_to_identity_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Receiver Identity Id:"); + + ui.text_edit_singleline(&mut self.receiver_identity_id); + }); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut app_action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Transfer") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + let identifier = if self.receiver_identity_id.is_empty() { + self.error_message = Some("Invalid identifier".to_string()); + self.transfer_tokens_status = + TransferTokensStatus::ErrorMessage("Invalid identifier".to_string()); + self.confirmation_popup = false; + return; + } else { + match Identifier::from_string_try_encodings( + &self.receiver_identity_id, + &[Encoding::Base58, Encoding::Hex], + ) { + Ok(identifier) => identifier, + Err(_) => { + self.error_message = Some("Invalid identifier".to_string()); + self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( + "Invalid identifier".to_string(), + ); + self.confirmation_popup = false; + return; + } + } + }; + + if self.selected_key.is_none() { + self.error_message = Some("No selected key".to_string()); + self.transfer_tokens_status = + TransferTokensStatus::ErrorMessage("No selected key".to_string()); + self.confirmation_popup = false; + return; + }; + + ui.label(format!( + "Are you sure you want to transfer {} tokens to {}", + self.amount, self.receiver_identity_id + )); + + if ui.button("Confirm").clicked() { + self.confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|contract| { + contract.contract.id() == self.identity_token_balance.data_contract_id + }) + .expect("Data contract not found") + .contract + .clone(); + app_action = AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(TokenTask::TransferTokens { + sending_identity: self.identity.clone(), + recipient_id: identifier, + amount: self.amount.parse().unwrap(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + }), + BackendTask::TokenTask(TokenTask::QueryMyTokenBalances), + ], + BackendTasksExecutionMode::Sequential, + ); + } + if ui.button("Cancel").clicked() { + self.confirmation_popup = false; + } + }); + if !is_open { + self.confirmation_popup = false; + } + app_action + } + + pub fn show_success(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Center the content vertically and horizontally + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Success!"); + + ui.add_space(20.0); + + // Display the "Back to Identities" button + if ui.button("Back to Tokens").clicked() { + // Handle navigation back to the identities screen + action = AppAction::PopScreenAndRefresh; + } + }); + + action + } +} + +impl ScreenLike for TransferTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + if message == "TransferTokens" { + self.transfer_tokens_status = TransferTokensStatus::Complete; + } + } + MessageType::Info => {} + MessageType::Error => { + // It's not great because the error message can be coming from somewhere else if there are other processes happening + self.transfer_tokens_status = + TransferTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + } + + fn refresh(&mut self) { + // Refresh the identity because there might be new keys + self.identity = self + .app_context + .load_local_qualified_identities() + .unwrap() + .into_iter() + .find(|identity| identity.identity.id() == self.identity.identity.id()) + .unwrap(); + let token_balances = self + .app_context + .db + .get_identity_token_balances(&self.app_context) + .expect("Token balances not loaded"); + self.max_amount = token_balances + .iter() + .find(|balance| balance.identity_id == self.identity.identity.id()) + .map(|balance| balance.balance) + .unwrap_or(0); + } + + /// Renders the UI components for the withdrawal screen + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Transfer", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + // Show the success screen if the transfer was successful + if self.transfer_tokens_status == TransferTokensStatus::Complete { + action = self.show_success(ui); + return; + } + + ui.heading("Transfer Funds"); + ui.add_space(10.0); + + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + egui::Color32::DARK_RED, + format!( + "You do not have any authentication keys loaded for this {} identity.", + self.identity.identity_type + ), + ); + ui.add_space(10.0); + + let key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + // Select the key to sign with + ui.heading("1. Select the key to sign the transaction with"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Input the amount to transfer + ui.heading("2. Input the amount to transfer"); + ui.add_space(5.0); + self.render_amount_input(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Input the ID of the identity to transfer to + ui.heading("3. ID of the identity to transfer to"); + ui.add_space(5.0); + self.render_to_identity_input(ui); + + ui.add_space(10.0); + + // Transfer button + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + let button = egui::Button::new(RichText::new("Transfer").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .rounding(3.0); + if ui.add(button).clicked() { + self.confirmation_popup = true; + } + + if self.confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Handle transfer status messages + ui.add_space(5.0); + match &self.transfer_tokens_status { + TransferTokensStatus::NotStarted => { + // Do nothing + } + TransferTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed_seconds = now - start_time; + + let display_time = if elapsed_seconds < 60 { + format!( + "{} second{}", + elapsed_seconds, + if elapsed_seconds == 1 { "" } else { "s" } + ) + } else { + let minutes = elapsed_seconds / 60; + let seconds = elapsed_seconds % 60; + format!( + "{} minute{} and {} second{}", + minutes, + if minutes == 1 { "" } else { "s" }, + seconds, + if seconds == 1 { "" } else { "s" } + ) + }; + + ui.label(format!( + "Transferring... Time taken so far: {}", + display_time + )); + } + TransferTokensStatus::ErrorMessage(msg) => { + ui.colored_label(egui::Color32::DARK_RED, format!("Error: {}", msg)); + } + TransferTokensStatus::Complete => { + // Handled above + } + } + } + }); + action + } +} + +impl ScreenWithWalletUnlock for TransferTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs new file mode 100644 index 000000000..f1804b7d1 --- /dev/null +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -0,0 +1,439 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, Context, Ui}; +use egui::RichText; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; + +use crate::app::AppAction; +use crate::backend_task::tokens::TokenTask; +use crate::backend_task::BackendTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::{MessageType, Screen, ScreenLike}; + +use super::tokens_screen::IdentityTokenBalance; + +/// The states for the unfreeze flow +#[derive(PartialEq)] +pub enum UnfreezeTokensStatus { + NotStarted, + WaitingForResult(u64), + ErrorMessage(String), + Complete, +} + +/// A screen that allows unfreezing a previously frozen identity’s tokens for a specific contract +pub struct UnfreezeTokensScreen { + pub identity: QualifiedIdentity, + pub identity_token_balance: IdentityTokenBalance, + selected_key: Option, + + /// Identity that’s currently frozen and we want to unfreeze + unfreeze_identity_id: String, + + status: UnfreezeTokensStatus, + error_message: Option, + + // Basic references + pub app_context: Arc, + + // Confirmation popup + show_confirmation_popup: bool, + + // If password-based wallet unlocking is needed + selected_wallet: Option>>, + wallet_password: String, + show_password: bool, +} + +impl UnfreezeTokensScreen { + pub fn new( + identity_token_balance: IdentityTokenBalance, + app_context: &Arc, + ) -> Self { + let identity = app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .find(|id| id.identity.id() == identity_token_balance.identity_id) + .expect("No local qualified identity found for this token’s identity"); + + let identity_clone = identity.identity.clone(); + let possible_key = identity_clone.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, possible_key.clone(), &mut error_message); + + Self { + identity, + identity_token_balance, + selected_key: possible_key.cloned(), + unfreeze_identity_id: String::new(), + status: UnfreezeTokensStatus::NotStarted, + error_message: None, + app_context: app_context.clone(), + show_confirmation_popup: false, + selected_wallet, + wallet_password: String::new(), + show_password: false, + } + } + + fn render_key_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Select Key:"); + egui::ComboBox::from_id_salt("unfreeze_key_selector") + .selected_text(match &self.selected_key { + Some(key) => format!("Key ID: {}", key.id()), + None => "Select a key".to_string(), + }) + .show_ui(ui, |ui| { + if self.app_context.developer_mode { + // Show all loaded public keys + for key in self.identity.identity.public_keys().values() { + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } else { + // Show only "available" auth keys + for key_wrapper in self.identity.available_authentication_keys() { + let key = &key_wrapper.identity_public_key; + let label = + format!("Key ID: {} (Purpose: {:?})", key.id(), key.purpose()); + ui.selectable_value(&mut self.selected_key, Some(key.clone()), label); + } + } + }); + }); + } + + fn render_unfreeze_identity_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Identity to Unfreeze:"); + ui.text_edit_singleline(&mut self.unfreeze_identity_id); + }); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut is_open = true; + egui::Window::new("Confirm Unfreeze") + .collapsible(false) + .open(&mut is_open) + .show(ui.ctx(), |ui| { + // Validate user input + let parsed = Identifier::from_string_try_encodings( + &self.unfreeze_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + if parsed.is_err() { + self.error_message = Some("Please enter a valid identity ID.".into()); + self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); + self.show_confirmation_popup = false; + return; + } + let unfreeze_id = parsed.unwrap(); + + ui.label(format!( + "Are you sure you want to unfreeze identity {}?", + self.unfreeze_identity_id + )); + + ui.add_space(10.0); + + // Confirm + if ui.button("Confirm").clicked() { + self.show_confirmation_popup = false; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = UnfreezeTokensStatus::WaitingForResult(now); + + let data_contract = self + .app_context + .get_contracts(None, None) + .expect("Contracts not loaded") + .iter() + .find(|c| c.contract.id() == self.identity_token_balance.data_contract_id) + .expect("Data contract not found") + .contract + .clone(); + + // Dispatch to backend + action = + AppAction::BackendTask(BackendTask::TokenTask(TokenTask::UnfreezeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + unfreeze_identity: unfreeze_id, + })); + } + + // Cancel + if ui.button("Cancel").clicked() { + self.show_confirmation_popup = false; + } + }); + + if !is_open { + self.show_confirmation_popup = false; + } + action + } + + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Unfroze Successfully!"); + + ui.add_space(20.0); + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action + } +} + +impl ScreenLike for UnfreezeTokensScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Success => { + // Possibly "UnfreezeTokens" or something else from your backend + if message.contains("Successfully unfroze identity") || message == "UnfreezeTokens" + { + self.status = UnfreezeTokensStatus::Complete; + } + } + MessageType::Error => { + self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + MessageType::Info => {} + } + } + + fn refresh(&mut self) { + if let Ok(all_identities) = self.app_context.load_local_qualified_identities() { + if let Some(updated_identity) = all_identities + .into_iter() + .find(|id| id.identity.id() == self.identity.identity.id()) + { + self.identity = updated_identity; + } + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + &self.identity_token_balance.token_name, + AppAction::PopScreen, + ), + ("Unfreeze", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + if self.status == UnfreezeTokensStatus::Complete { + action = self.show_success_screen(ui); + return; + } + + ui.heading("Unfreeze a Frozen Identity’s Tokens"); + ui.add_space(10.0); + + // Check if user has any auth keys + let has_keys = if self.app_context.developer_mode { + !self.identity.identity.public_keys().is_empty() + } else { + !self.identity.available_authentication_keys().is_empty() + }; + + if !has_keys { + ui.colored_label( + Color32::RED, + format!( + "No authentication keys found for this {} identity.", + self.identity.identity_type, + ), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + SecurityLevel::CRITICAL, + ]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + self.identity.clone(), + &self.app_context, + ))); + } + } else { + // Possibly handle locked wallet scenario + if self.selected_wallet.is_some() { + let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + + if needed_unlock && !just_unlocked { + return; + } + } + + // 1) Key selection + ui.heading("1. Select the key to sign the Unfreeze transition"); + ui.add_space(10.0); + ui.horizontal(|ui| { + self.render_key_selection(ui); + ui.add_space(5.0); + let identity_id_string = + self.identity.identity.id().to_string(Encoding::Base58); + let identity_display = self + .identity + .alias + .as_deref() + .unwrap_or_else(|| &identity_id_string); + ui.label(format!("Identity: {}", identity_display)); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // 2) Identity to unfreeze + ui.heading("2. Enter the identity ID to unfreeze"); + ui.add_space(5.0); + self.render_unfreeze_identity_input(ui); + + ui.add_space(10.0); + + // Unfreeze button + let button = egui::Button::new(RichText::new("Unfreeze").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 128)) + .rounding(3.0); + + if ui.add(button).clicked() { + self.show_confirmation_popup = true; + } + + // If user pressed "Unfreeze," show popup + if self.show_confirmation_popup { + action |= self.show_confirmation_popup(ui); + } + + // Show in-progress or error messages + ui.add_space(10.0); + match &self.status { + UnfreezeTokensStatus::NotStarted => { + // no-op + } + UnfreezeTokensStatus::WaitingForResult(start_time) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed = now - start_time; + ui.label(format!("Unfreezing... elapsed: {}s", elapsed)); + } + UnfreezeTokensStatus::ErrorMessage(msg) => { + ui.colored_label(Color32::RED, format!("Error: {}", msg)); + } + UnfreezeTokensStatus::Complete => { + // handled above + } + } + } + }); + + action + } +} + +impl ScreenWithWalletUnlock for UnfreezeTokensScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index aba6a6b24..18a7808cd 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(PartialEq)] -pub enum TransitionBroadcastStatus { +enum TransitionBroadcastStatus { NotStarted, Submitting(TimestampMillis), Error(String),