diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 656c0d2a9..79ff3d252 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -219,6 +219,7 @@ pub enum BackendTaskSuccessResult { SetTokenPrice(FeeResult), DestroyedFrozenFunds(FeeResult), ClaimedTokens(FeeResult), + TokensClaimed(TokenAmount), UpdatedTokenConfig(String, FeeResult), // The config item that was updated FetchedTokenBalances, SavedToken, diff --git a/src/backend_task/tokens/claim_tokens.rs b/src/backend_task/tokens/claim_tokens.rs index ec8d6a9ba..db7a6a4f4 100644 --- a/src/backend_task/tokens/claim_tokens.rs +++ b/src/backend_task/tokens/claim_tokens.rs @@ -2,6 +2,9 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; use dash_sdk::dpp::document::DocumentV0Getters; @@ -14,8 +17,14 @@ use dash_sdk::{Error, Sdk}; use std::sync::Arc; impl AppContext { + /// Claim all pending token claims matching the provided parameters. + /// + /// This method iterates until all tokens are claimed or no more claims are available. + /// + /// If no tokens are available to claim, it returns `TokensClaimed(0)` + /// (in contrary to the [AppContext::claim_token()] method which returns error). #[allow(clippy::too_many_arguments)] - pub async fn claim_tokens( + pub async fn claim_all_tokens( &self, data_contract: Arc, token_position: u16, @@ -25,6 +34,69 @@ impl AppContext { public_note: Option, sdk: &Sdk, ) -> Result { + let mut total_claimed = 0; + loop { + let result = self + .claim_token( + data_contract.clone(), + token_position, + actor_identity, + distribution_type, + signing_key.clone(), + public_note.clone(), + sdk, + ) + .await; + + match result { + Ok(BackendTaskSuccessResult::TokensClaimed(0)) => { + // If no tokens were claimed, we can exit the loop + break; + } + Ok(BackendTaskSuccessResult::TokensClaimed(amount)) => { + total_claimed += amount; + // Continue to check for more tokens to claim + } + Err(dash_sdk::Error::Protocol(ProtocolError::ConsensusError(ce))) + if matches!( + *ce, + ConsensusError::StateError(StateError::InvalidTokenClaimNoCurrentRewards( + _ + )), + ) => + { + // No more rewards available, exit the loop + break; + } + // any other result we propagate + Ok(x) => return Ok(x), + Err(e) => { + return Err(format!( + "Error claiming tokens: {}; claimed so far: {}", + e, total_claimed + )); + } + } + } + + // Return the total claimed amount. + Ok(BackendTaskSuccessResult::TokensClaimed(total_claimed)) + } + + /// Execute single token claim. + /// + /// This method will return error if no tokens were available to claim. + #[allow(clippy::too_many_arguments)] + pub async fn claim_token( + &self, + data_contract: Arc, + token_position: u16, + actor_identity: &QualifiedIdentity, + distribution_type: TokenDistributionType, + signing_key: IdentityPublicKey, + public_note: Option, + sdk: &Sdk, + ) -> Result { // Build let mut builder = TokenClaimTransitionBuilder::new( data_contract.clone(), @@ -45,7 +117,7 @@ impl AppContext { let result = sdk .token_claim(builder, &signing_key, actor_identity) .await - .map_err(|e| match e { + .inspect_err(|e|{ match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { self.db .insert_proof_log_item(ProofLogItem { @@ -54,19 +126,18 @@ impl AppContext { verification_path_query_bytes: vec![], height: block_info.height, time_ms: block_info.time_ms, - proof_bytes, + proof_bytes: proof_bytes.clone(), error: Some(proof_error.to_string()), }) .ok(); - format!( - "Error broadcasting ClaimTokens transition: {}, proof error logged", - proof_error - ) + tracing::error!(error=?proof_error, "Error broadcasting ClaimTokens transition, proof error logged"); } - e => format!("Error broadcasting ClaimTokens transition: {}", e), - })?; + e => tracing::error!(error=?e, "Error broadcasting ClaimTokens transition"), + }; + })?; // Using the result, update the balance of the claimer identity + let mut claimed_amount = 0; if let Some(token_id) = data_contract.token_id(token_position) { match result { // Standard claim result - extract claimer and amount from document @@ -75,14 +146,16 @@ impl AppContext { (document.get("claimerId"), document.get("amount")) && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = (claimer_value, amount_value) - && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &claimer_id, *amount) { - tracing::error!( - "Failed to update token balance from claim document: {}", - e - ); + claimed_amount = *amount; + if let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &claimer_id, *amount) + { + tracing::error!(error=?e, identity=?claimer_id, token_id=?token_id, + "Failed to update token balance from claim document", + ); + } } } @@ -92,24 +165,22 @@ impl AppContext { (document.get("claimerId"), document.get("amount")) && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = (claimer_value, amount_value) - && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &claimer_id, *amount) { - tracing::error!( - "Failed to update token balance from group action document: {}", - e - ); + claimed_amount = *amount; + if let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &claimer_id, *amount) + { + tracing::error!(error=?e, identity=?claimer_id, token_id=?token_id, + "Failed to update token balance from group action document", + ); + } } } } } - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::ClaimedTokens(fee_result)) + // Return success + Ok(BackendTaskSuccessResult::TokensClaimed(claimed_amount)) } } diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index 6ff94cb45..8f7ffad12 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -12,6 +12,8 @@ use dash_sdk::{ Sdk, dpp::{ ProtocolError, + consensus::ConsensusError, + consensus::state::state_error::StateError, data_contract::{ TokenConfiguration, TokenContractPosition, associated_token::{ @@ -183,6 +185,7 @@ pub enum TokenTask { distribution_type: TokenDistributionType, signing_key: IdentityPublicKey, public_note: Option, + claim_all: bool, }, EstimatePerpetualTokenRewardsWithExplanation { identity_id: Identifier, @@ -483,18 +486,48 @@ impl AppContext { distribution_type, signing_key, public_note, - } => self - .claim_tokens( - data_contract.clone(), - *token_position, - actor_identity, - *distribution_type, - signing_key.clone(), - public_note.clone(), - sdk, - ) - .await - .map_err(|e| format!("Failed to claim tokens: {e}")), + claim_all, + } => { + if *claim_all { + self.claim_all_tokens( + data_contract.clone(), + *token_position, + actor_identity, + *distribution_type, + signing_key.clone(), + public_note.clone(), + sdk, + ) + .await + .map_err(|e| format!("Failed to claim all tokens: {e}")) + } else { + match self + .claim_token( + data_contract.clone(), + *token_position, + actor_identity, + *distribution_type, + signing_key.clone(), + public_note.clone(), + sdk, + ) + .await + { + Err(dash_sdk::Error::Protocol(ProtocolError::ConsensusError(ce))) + if matches!( + *ce, + ConsensusError::StateError( + StateError::InvalidTokenClaimNoCurrentRewards(_) + ), + ) => + { + Ok(BackendTaskSuccessResult::TokensClaimed(0)) + } + Ok(result) => Ok(result), + Err(e) => Err(format!("Failed to claim tokens: {e}")), + } + } + } TokenTask::EstimatePerpetualTokenRewardsWithExplanation { identity_id, token_id, diff --git a/src/database/identities.rs b/src/database/identities.rs index 6cb2a25fe..4333ad2c2 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -114,6 +114,39 @@ impl Database { Ok(()) } + #[allow(dead_code)] // May be used for caching remote identities from network queries + pub fn insert_remote_identity_if_not_exists( + &self, + identifier: &Identifier, + qualified_identity: Option<&QualifiedIdentity>, + app_context: &AppContext, + ) -> rusqlite::Result<()> { + let id = identifier.to_vec(); + let alias = qualified_identity.and_then(|qi| qi.alias.clone()); + let identity_type = + qualified_identity.map_or("".to_string(), |qi| format!("{:?}", qi.identity_type)); + let data = qualified_identity.map(|qi| qi.to_bytes()); + + let network = app_context.network.to_string(); + + // Check if the identity already exists + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT COUNT(*) FROM identity WHERE id = ? AND network = ?")?; + let count: i64 = stmt.query_row(params![id, network], |row| row.get(0))?; + + // If the identity doesn't exist, insert it + if count == 0 { + self.execute( + "INSERT INTO identity (id, data, is_local, alias, identity_type, network) + VALUES (?, ?, 0, ?, ?, ?)", + params![id, data, alias, identity_type, network], + )?; + } + + Ok(()) + } + /// Returns all local identities for the current network. /// /// Stops on the first corrupted identity blob and returns an error. diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 2034815e8..5e018b671 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -63,6 +63,8 @@ pub struct ClaimTokensScreen { confirmation_dialog: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, + claim_all: bool, + claimed_amount: Option, // Fee result from completed operation completed_fee_result: Option, } @@ -79,7 +81,7 @@ impl ClaimTokensScreen { .unwrap_or_default() .into_iter() .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .expect("No local qualified identity found for this token’s identity."); + .expect("No local qualified identity found for this token's identity."); let identity_clone = identity.identity.clone(); let mut possible_key = identity_clone.get_first_public_key_matching( @@ -133,6 +135,8 @@ impl ClaimTokensScreen { confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), + claim_all: true, + claimed_amount: None, completed_fee_result: None, } } @@ -228,6 +232,7 @@ impl ClaimTokensScreen { distribution_type, signing_key, public_note: self.public_note.clone(), + claim_all: self.claim_all, })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), ], @@ -243,12 +248,26 @@ impl ClaimTokensScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_success_screen_with_info( - ui, - "Claimed Successfully!".to_string(), - vec![("Back to Tokens".to_string(), AppAction::PopScreenAndRefresh)], - None, - ) + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Claimed Successfully!"); + + ui.add_space(20.0); + + // Show amount claimed if available + if let Some(amount) = self.claimed_amount { + ui.label(format!("Amount claimed: {} tokens", amount)); + ui.add_space(10.0); + } + + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + }); + action } } @@ -261,9 +280,22 @@ impl ScreenLike for ClaimTokensScreen { } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::ClaimedTokens(fee_result) = backend_task_success_result { - self.completed_fee_result = Some(fee_result); - self.status = ClaimTokensStatus::Complete; + match backend_task_success_result { + BackendTaskSuccessResult::ClaimedTokens(fee_result) => { + self.completed_fee_result = Some(fee_result); + self.status = ClaimTokensStatus::Complete; + } + BackendTaskSuccessResult::TokensClaimed(amount) => { + self.claimed_amount = Some(amount); + if amount > 0 { + self.status = ClaimTokensStatus::Complete; + } else { + self.status = ClaimTokensStatus::ErrorMessage( + "No tokens available to claim.".to_string(), + ); + } + } + _ => {} } } @@ -521,6 +553,13 @@ impl ScreenLike for ClaimTokensScreen { ui.add_space(10.0); } + // Add "Claim all" checkbox + ui.horizontal(|ui| { + ui.checkbox(&mut self.claim_all, "Claim all") + .on_hover_text("When enabled, automatically claims all available tokens by repeating claims until no more tokens are available. When disabled, performs a single claim operation."); + }); + ui.add_space(10.0); + // Fee estimation display let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 899840c16..5c9b640d2 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -16,7 +16,7 @@ use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; use egui::load::SizedTexture; -use egui::{Color32, Frame, Margin, RichText, TextureOptions}; +use egui::{Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; use super::WalletsBalancesScreen;