diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 7dd036a19..d07f7e030 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -196,20 +196,21 @@ impl AppContext { let wallet = wallet_arc.read().map_err(|e| e.to_string())?; for (core_addr, platform_addr) in wallet.platform_addresses(self.network) { if let Some(info) = wallet.get_platform_address_info(&core_addr) { - // Only pre-populate if we have a last_synced_balance + // Only pre-populate if we have a last_full_sync_balance // (meaning this address was found in a previous full sync) - if let Some(synced_balance) = info.last_synced_balance { + // This prevents double-counting AddToCredits after app restart + if let Some(full_sync_balance) = info.last_full_sync_balance { let lookup_addr = platform_addr.to_address_with_network(self.network); - provider.update_balance(&lookup_addr, synced_balance); + provider.update_balance(&lookup_addr, full_sync_balance); pre_populated_count += 1; tracing::debug!( - "Pre-populated balance for {}: {} (last synced)", + "Pre-populated balance for {}: {} (from last full sync)", platform_addr.to_bech32m_string(self.network), - synced_balance + full_sync_balance ); } else { tracing::debug!( - "Skipping pre-population for {} (no last_synced_balance, likely from proof)", + "Skipping pre-population for {} (no last_full_sync_balance, needs full sync)", platform_addr.to_bech32m_string(self.network) ); } @@ -259,6 +260,7 @@ impl AppContext { let balances = { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + // Update wallet with synced balances (also updates last_full_sync_balance for next sync) provider.apply_results_to_wallet(&mut wallet); // Persist addresses and balances to database @@ -284,12 +286,14 @@ impl AppContext { // Persist balance to platform_address_balances table // Use the nonce from AddressFunds which comes directly from SDK sync + // This is a sync operation, so update last_full_sync_balance if let Err(e) = self.db.set_platform_address_info( &seed_hash, address, funds.balance, funds.nonce, &self.network, + true, // Sync operation - update last_full_sync_balance ) { tracing::warn!("Failed to persist Platform address info: {}", e); } diff --git a/src/context.rs b/src/context.rs index cb96b06d6..200ef77fa 100644 --- a/src/context.rs +++ b/src/context.rs @@ -572,13 +572,15 @@ impl AppContext { // Update in-memory wallet state wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - // Update database + // Update database (not a sync operation - preserve last_full_sync_balance + // so the next terminal sync can correctly apply any pending AddToCredits) if let Err(e) = self.db.set_platform_address_info( &seed_hash, &core_addr, info.balance, info.nonce, &self.network, + false, // Not a sync operation ) { tracing::warn!("Failed to store Platform address info in database: {}", e); } diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 02f747576..b09c7b071 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 25; +pub const DEFAULT_DB_VERSION: u16 = 26; pub const DEFAULT_NETWORK: &str = "dash"; @@ -51,6 +51,9 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 26 => { + self.add_last_full_sync_balance_column(tx)?; + } 25 => { self.add_avatar_bytes_column(tx)?; } @@ -342,6 +345,7 @@ impl Database { nonce INTEGER NOT NULL DEFAULT 0, network TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0, + last_full_sync_balance INTEGER DEFAULT NULL, PRIMARY KEY (seed_hash, address, network), FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", @@ -784,6 +788,40 @@ impl Database { Ok(()) } + + /// Migration: Add last_full_sync_balance column to platform_address_balances table (version 26). + /// Stores the balance from the last FULL sync (checkpoint), separate from the current balance + /// which includes terminal sync updates. This prevents double-counting AddToCredits during + /// terminal-only syncs after app restart. + fn add_last_full_sync_balance_column(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if platform_address_balances table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='platform_address_balances'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if last_full_sync_balance column already exists + let has_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('platform_address_balances') WHERE name='last_full_sync_balance'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_column { + // Add column with NULL default - existing rows will need a full sync to populate + conn.execute( + "ALTER TABLE platform_address_balances ADD COLUMN last_full_sync_balance INTEGER DEFAULT NULL", + [], + )?; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/src/database/wallet.rs b/src/database/wallet.rs index a0918cc68..92226981d 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -837,20 +837,27 @@ impl Database { ); // Load platform address info for each wallet (using existing connection to avoid deadlock) let mut platform_stmt = conn.prepare( - "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", + "SELECT seed_hash, address, balance, nonce, last_full_sync_balance FROM platform_address_balances WHERE network = ?", )?; let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { let seed_hash: Vec = row.get(0)?; let address_str: String = row.get(1)?; let balance: i64 = row.get(2)?; let nonce: i64 = row.get(3)?; + let last_full_sync_balance: Option = row.get(4)?; let seed_hash_array: [u8; 32] = seed_hash.try_into().expect("Seed hash should be 32 bytes"); - Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) + Ok(( + seed_hash_array, + address_str, + balance as u64, + nonce as u32, + last_full_sync_balance.map(|b| b as u64), + )) })?; for row in platform_rows { - if let Ok((seed_hash, address_str, balance, nonce)) = row + if let Ok((seed_hash, address_str, balance, nonce, last_full_sync_balance)) = row && let Some(wallet) = wallets_map.get_mut(&seed_hash) && let Ok(address) = Address::::from_str(&address_str) { @@ -869,8 +876,9 @@ impl Database { crate::model::wallet::PlatformAddressInfo { balance, nonce, - // Assume database balance is from sync (safe default) - last_synced_balance: Some(balance), + // Use the stored last_full_sync_balance from the database + // This is the balance from the last FULL sync checkpoint, not including terminal updates + last_full_sync_balance, }, ); } @@ -880,7 +888,12 @@ impl Database { Ok(wallets_map.into_values().collect()) } - /// Store or update Platform address balance and nonce + /// Store or update Platform address balance and nonce. + /// + /// When `is_sync_operation` is true, also updates `last_full_sync_balance` to the current + /// balance. This should be true for sync operations (full or terminal) and false for + /// internal updates (e.g., after a transfer completes), so that subsequent terminal syncs + /// can correctly apply any pending AddToCredits. pub fn set_platform_address_info( &self, seed_hash: &[u8; 32], @@ -888,6 +901,7 @@ impl Database { balance: u64, nonce: u32, network: &Network, + is_sync_operation: bool, ) -> rusqlite::Result<()> { let network_str = network.to_string(); let canonical_address = Wallet::canonical_address(address, *network); @@ -897,19 +911,49 @@ impl Database { .unwrap_or_default() .as_secs() as i64; - self.execute( - "INSERT OR REPLACE INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at) - VALUES (?, ?, ?, ?, ?, ?)", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at - ], - )?; + if is_sync_operation { + // Sync operation: update both balance and last_full_sync_balance + // last_full_sync_balance becomes the baseline for pre-population in the next sync + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at, + last_full_sync_balance = excluded.last_full_sync_balance", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at, + balance as i64 + ], + )?; + } else { + // Internal update (e.g., after transfer): update balance but preserve last_full_sync_balance + // This ensures the next terminal sync correctly applies any pending AddToCredits + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) + VALUES (?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at + ], + )?; + } Ok(()) } @@ -1228,7 +1272,7 @@ mod tests { assert!(info.is_none()); // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) .expect("Failed to set platform address info"); // Retrieve it @@ -1241,7 +1285,7 @@ mod tests { assert_eq!(info.1, 5); // nonce // Update it - db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network) + db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network, true) .expect("Failed to update platform address info"); let info = db @@ -1339,7 +1383,7 @@ mod tests { // Add a single valid platform address using the helper function let address = create_test_address(network); - db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network) + db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network, true) .expect("Failed to set platform address info"); // Get all addresses @@ -1377,7 +1421,7 @@ mod tests { } // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) .expect("Failed to set platform address info"); // Verify it exists diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 346feac24..f2c3209a9 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -288,9 +288,10 @@ impl PartialEq for WalletArcRef { pub struct PlatformAddressInfo { pub balance: Credits, pub nonce: AddressNonce, - /// Balance as of last full sync (used for terminal-only sync pre-population) - /// This prevents double-counting when proof-verified updates happen between syncs - pub last_synced_balance: Option, + /// Balance recorded at the last sync checkpoint. Updated by `set_platform_address_info_from_sync` + /// during both full and terminal syncs; preserved by `set_platform_address_info` during internal + /// updates (e.g., after transfers) to avoid double-counting AddToCredits on subsequent syncs. + pub last_full_sync_balance: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1890,49 +1891,70 @@ impl Wallet { nonce: AddressNonce, ) { // Convert the incoming address to PlatformAddress for canonical comparison - if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { - let canonical_bytes = platform_addr.to_bytes(); - - // Find and remove any existing entry that represents the same platform address - // but might have a different Address representation - let keys_to_remove: Vec
= self - .platform_address_info - .keys() - .filter(|existing_addr| { - if let Ok(existing_platform) = - PlatformAddress::try_from((*existing_addr).clone()) - { - existing_platform.to_bytes() == canonical_bytes - && *existing_addr != &address - } else { - false - } - }) - .cloned() - .collect(); - - for key in keys_to_remove { - self.platform_address_info.remove(&key); - } + let (keys_to_remove, last_full_sync_balance) = + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + + // First, find last_full_sync_balance from any canonical-equivalent entry + // (must be done BEFORE removing duplicates) + let last_full_sync_balance = + self.platform_address_info + .iter() + .find_map(|(existing_addr, info)| { + if let Ok(existing_platform) = + PlatformAddress::try_from(existing_addr.clone()) + && existing_platform.to_bytes() == canonical_bytes + { + return info.last_full_sync_balance; + } + None + }); + + // Find duplicate entries to remove (same platform address, different key) + let keys_to_remove: Vec
= self + .platform_address_info + .keys() + .filter(|existing_addr| { + if let Ok(existing_platform) = + PlatformAddress::try_from((*existing_addr).clone()) + { + existing_platform.to_bytes() == canonical_bytes + && *existing_addr != &address + } else { + false + } + }) + .cloned() + .collect(); + + (keys_to_remove, last_full_sync_balance) + } else { + // Fallback: try direct lookup if canonical conversion fails + let last_full_sync_balance = self + .platform_address_info + .get(&address) + .and_then(|info| info.last_full_sync_balance); + (vec![], last_full_sync_balance) + }; + + // Remove duplicate entries + for key in keys_to_remove { + self.platform_address_info.remove(&key); } - // Preserve last_synced_balance if it exists - let last_synced_balance = self - .platform_address_info - .get(&address) - .and_then(|info| info.last_synced_balance); - self.platform_address_info.insert( address, PlatformAddressInfo { balance, nonce, - last_synced_balance, + last_full_sync_balance, }, ); } - /// Set platform address info from a sync operation (updates last_synced_balance) + /// Set platform address info from a sync operation. + /// Always updates `last_full_sync_balance` to the current balance, as this becomes + /// the baseline for pre-population in the next terminal sync. pub fn set_platform_address_info_from_sync( &mut self, address: Address, @@ -1944,7 +1966,8 @@ impl Wallet { PlatformAddressInfo { balance, nonce, - last_synced_balance: Some(balance), + // Always update to current balance - this is the baseline for next sync + last_full_sync_balance: Some(balance), }, ); } @@ -2216,7 +2239,7 @@ impl WalletAddressProvider { for (address, funds) in &self.found_balances { let canonical_address = Wallet::canonical_address(address, self.network); - // Use sync-specific method that also updates last_synced_balance + // Update wallet with synced balance (also updates last_full_sync_balance for next sync) wallet.set_platform_address_info_from_sync( canonical_address.clone(), funds.balance,