diff --git a/Cargo.lock b/Cargo.lock index 974d2a979e..0e309a219d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "bincode", "bincode_derive", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "dash-network", ] @@ -1656,7 +1656,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "async-trait", "chrono", @@ -1684,7 +1684,7 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1703,7 +1703,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "anyhow", "base64-compat", @@ -1729,12 +1729,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "dashcore-rpc-json", "hex", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "bincode", "dashcore", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "bincode", "dashcore-private", @@ -3811,7 +3811,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "aes", "async-trait", @@ -3839,7 +3839,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3855,7 +3855,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8fe9ea394306e5f9282505b24d09092ab9a33738#8fe9ea394306e5f9282505b24d09092ab9a33738" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 2f87cfd9df..c4ddb2882f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,14 +49,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "8fe9ea394306e5f9282505b24d09092ab9a33738" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e9115dfa64..3da3aad797 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -361,18 +361,39 @@ fn account_index_of(at: &key_wallet::account::AccountType) -> u32 { } } +/// Per-account balance entry returned by the query FFI. Carries the +/// same `AccountTypeTagFFI` discriminants as `AccountSpecFFI` plus +/// four balance fields from `WalletCoreBalance`. +#[repr(C)] +pub struct AccountBalanceEntryFFI { + pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, + pub standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub confirmed: u64, + pub unconfirmed: u64, + pub immature: u64, + pub locked: u64, +} + /// Subset of [`crate::wallet_restore_types::AccountSpecFFI`] carrying /// only the tag/discriminator fields — no xpub. Used by the /// changeset emit path to populate /// [`AccountChangeSetFFI`]'s typed tags so the Swift persister can -/// upsert on the same composite key the load path uses. -struct AccountChangeSetTags { - type_tag: crate::wallet_restore_types::AccountTypeTagFFI, - standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, - registration_index: u32, - key_class: u32, - user_identity_id: [u8; 32], - friend_identity_id: [u8; 32], +/// upsert on the same composite key the load path uses. Also used by +/// [`AccountBalanceEntryFFI`] to carry per-account routing context +/// for balance queries. +pub struct AccountChangeSetTags { + pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, + pub standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], } /// Project an upstream [`AccountType`] into the flat FFI tag layout. @@ -381,12 +402,13 @@ struct AccountChangeSetTags { /// match arms but emits only the tag/discriminator fields — the /// xpub is load-path-only and not relevant on the changeset emit /// path. -fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountChangeSetTags { +pub fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountChangeSetTags { use crate::wallet_restore_types::{AccountTypeTagFFI, StandardAccountTypeTagFFI}; use key_wallet::account::{AccountType, StandardAccountType}; let mut tags = AccountChangeSetTags { type_tag: AccountTypeTagFFI::Standard, standard_tag: StandardAccountTypeTagFFI::Bip44, + index: 0, registration_index: 0, key_class: 0, user_identity_id: [0u8; 32], @@ -394,17 +416,19 @@ fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountChangeS }; match at { AccountType::Standard { + index, standard_account_type, - .. } => { + tags.index = *index; tags.type_tag = AccountTypeTagFFI::Standard; tags.standard_tag = match standard_account_type { StandardAccountType::BIP44Account => StandardAccountTypeTagFFI::Bip44, StandardAccountType::BIP32Account => StandardAccountTypeTagFFI::Bip32, }; } - AccountType::CoinJoin { .. } => { + AccountType::CoinJoin { index } => { tags.type_tag = AccountTypeTagFFI::CoinJoin; + tags.index = *index; } AccountType::IdentityRegistration => { tags.type_tag = AccountTypeTagFFI::IdentityRegistration; @@ -438,25 +462,28 @@ fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountChangeS tags.type_tag = AccountTypeTagFFI::ProviderPlatformKeys; } AccountType::DashpayReceivingFunds { + index, user_identity_id, friend_identity_id, - .. } => { tags.type_tag = AccountTypeTagFFI::DashpayReceivingFunds; + tags.index = *index; tags.user_identity_id = *user_identity_id; tags.friend_identity_id = *friend_identity_id; } AccountType::DashpayExternalAccount { + index, user_identity_id, friend_identity_id, - .. } => { tags.type_tag = AccountTypeTagFFI::DashpayExternalAccount; + tags.index = *index; tags.user_identity_id = *user_identity_id; tags.friend_identity_id = *friend_identity_id; } - AccountType::PlatformPayment { key_class, .. } => { + AccountType::PlatformPayment { account, key_class } => { tags.type_tag = AccountTypeTagFFI::PlatformPayment; + tags.index = *account; tags.key_class = *key_class; } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index eff99d2ff3..fecdd88cb1 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1195,7 +1195,7 @@ fn build_wallet_start_state( // unlock path builds a signing wallet from the mnemonic. let wallet = Wallet::new_watch_only(network, entry.wallet_id, accounts); - let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + let wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); let mut per_account = PerWalletPlatformAddressState::new(); for (&account_key, account) in &wallet.accounts.platform_payment_accounts { diff --git a/packages/rs-platform-wallet-ffi/src/platform_wallet_info.rs b/packages/rs-platform-wallet-ffi/src/platform_wallet_info.rs index f51039e1ab..542b5ab4d0 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_wallet_info.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_wallet_info.rs @@ -88,7 +88,7 @@ pub unsafe extern "C" fn platform_wallet_info_create_from_seed( }; // Create PlatformWalletInfo from the wallet - let platform_wallet = PlatformWalletInfo::from_wallet(&wallet); + let platform_wallet = PlatformWalletInfo::from_wallet(&wallet, 0); // Store in handle storage let handle = WALLET_INFO_STORAGE.insert(platform_wallet); @@ -235,7 +235,7 @@ pub unsafe extern "C" fn platform_wallet_info_create_from_mnemonic( }; // Create PlatformWalletInfo from the wallet - let platform_wallet = PlatformWalletInfo::from_wallet(&wallet); + let platform_wallet = PlatformWalletInfo::from_wallet(&wallet, 0); // Store in handle storage let handle = WALLET_INFO_STORAGE.insert(platform_wallet); diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 3d388753cf..086f18ac66 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -168,6 +168,84 @@ pub unsafe extern "C" fn platform_wallet_load_and_apply_persisted( .unwrap_or(PlatformWalletFFIResult::ErrorInvalidHandle) } +/// Query per-account balances from the in-memory `WalletManager`. +/// +/// Returns an array of [`AccountBalanceEntryFFI`] — one per account +/// in the wallet's `ManagedAccountCollection`. The caller owns the +/// returned array and must free it via +/// [`platform_wallet_manager_free_account_balances`]. +/// +/// `out_entries` receives a pointer to the heap-allocated array; +/// `out_count` receives the element count. Both are set to +/// null / 0 when the wallet is not found. +/// +/// Reads the wallet manager lock via `blocking_read` — must not be +/// called from within a tokio async context. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( + manager_handle: Handle, + wallet_id: *const u8, + out_entries: *mut *const crate::core_wallet_types::AccountBalanceEntryFFI, + out_count: *mut usize, + _out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if wallet_id.is_null() || out_entries.is_null() || out_count.is_null() { + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |manager| { + let balances = manager.account_balances_blocking(&wid); + let entries: Vec = balances + .into_iter() + .map(|(account_type, balance)| { + let tags = crate::core_wallet_types::account_type_to_tags(&account_type); + crate::core_wallet_types::AccountBalanceEntryFFI { + type_tag: tags.type_tag, + standard_tag: tags.standard_tag, + index: tags.index, + registration_index: tags.registration_index, + key_class: tags.key_class, + user_identity_id: tags.user_identity_id, + friend_identity_id: tags.friend_identity_id, + confirmed: balance.confirmed(), + unconfirmed: balance.unconfirmed(), + immature: balance.immature(), + locked: balance.locked(), + } + }) + .collect(); + let count = entries.len(); + if count == 0 { + *out_entries = std::ptr::null(); + *out_count = 0; + return PlatformWalletFFIResult::Success; + } + let boxed = entries.into_boxed_slice(); + *out_entries = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::Success + }) + .unwrap_or_else(|| { + *out_entries = std::ptr::null(); + *out_count = 0; + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + +/// Free an array returned by [`platform_wallet_manager_get_account_balances`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( + entries: *mut crate::core_wallet_types::AccountBalanceEntryFFI, + count: usize, +) { + if !entries.is_null() && count > 0 { + let _ = Box::from_raw(std::slice::from_raw_parts_mut(entries, count)); + } +} + /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy( diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7a5d34292e..5b912a9ed9 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -2,6 +2,9 @@ use std::sync::Arc; +use key_wallet::account::AccountType; +use key_wallet::WalletCoreBalance; + use crate::changeset::PlatformWalletPersistence; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; @@ -63,4 +66,44 @@ impl PlatformWalletManager

{ let wallets = self.wallets.read().await; wallets.keys().copied().collect() } + + /// Read per-account balance snapshots for a wallet. + /// + /// Returns the current `WalletCoreBalance` for every account in the + /// wallet's `ManagedAccountCollection`. Each entry's balance is the + /// live in-memory value maintained by `update_balance()` during SPV + /// processing — no disk I/O. Uses `blocking_read` on the wallet + /// manager lock; safe from non-async FFI context but must NOT be + /// called from within a tokio async task. + pub fn account_balances_blocking( + &self, + wallet_id: &WalletId, + ) -> Vec<(AccountType, WalletCoreBalance)> { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + info.core_wallet + .accounts + .all_accounts() + .iter() + .filter(|account| { + matches!( + account.managed_account_type.to_account_type(), + AccountType::Standard { .. } + | AccountType::CoinJoin { .. } + | AccountType::IdentityTopUp { .. } + | AccountType::DashpayReceivingFunds { .. } + | AccountType::DashpayExternalAccount { .. } + | AccountType::PlatformPayment { .. } + ) + }) + .map(|account| { + ( + account.managed_account_type.to_account_type(), + account.balance, + ) + }) + .collect() + } } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 09e3951b7c..cf751b0480 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -98,7 +98,7 @@ impl PlatformWalletManager

{ &self, wallet: Wallet, ) -> Result, PlatformWalletError> { - let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + let wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); let balance = Arc::new(WalletBalance::new()); @@ -129,9 +129,9 @@ impl PlatformWalletManager

{ .all_managed_accounts() .iter() .map(|managed| { - let account_type = managed.account_type.to_account_type(); + let account_type = managed.managed_account_type.to_account_type(); let pools = managed - .account_type + .managed_account_type .address_pools() .iter() .map(|pool| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index 49fba4e9d7..c376a8d825 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -225,7 +225,7 @@ impl IdentityWallet { user_identity_id, friend_identity_id, .. - } = &account.account_type + } = &account.managed_account_type else { // Routing invariant: dashpay_receival_accounts must // only contain DashpayReceivingFunds. If this ever diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 3b3b04d846..0e3917bff6 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -28,10 +28,10 @@ use super::platform_wallet::PlatformWalletInfo; // --------------------------------------------------------------------------- impl WalletInfoInterface for PlatformWalletInfo { - fn from_wallet(wallet: &Wallet) -> Self { + fn from_wallet(wallet: &Wallet, birth_height: CoreBlockHeight) -> Self { use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - let inner = ManagedWalletInfo::from_wallet(wallet); + let inner = ManagedWalletInfo::from_wallet(wallet, birth_height); Self { core_wallet: inner, balance: std::sync::Arc::new(super::core::WalletBalance::new()), @@ -40,10 +40,10 @@ impl WalletInfoInterface for PlatformWalletInfo { } } - fn from_wallet_with_name(wallet: &Wallet, name: String) -> Self { + fn from_wallet_with_name(wallet: &Wallet, name: String, birth_height: CoreBlockHeight) -> Self { use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - let inner = ManagedWalletInfo::from_wallet_with_name(wallet, name); + let inner = ManagedWalletInfo::from_wallet_with_name(wallet, name, birth_height); Self { core_wallet: inner, balance: std::sync::Arc::new(super::core::WalletBalance::new()), @@ -80,10 +80,6 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.birth_height() } - fn set_birth_height(&mut self, height: CoreBlockHeight) { - self.core_wallet.set_birth_height(height); - } - fn first_loaded_at(&self) -> u64 { self.core_wallet.first_loaded_at() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 2fdb17ff02..84d01eb5e8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -370,6 +370,85 @@ public class PlatformWalletManager: ObservableObject { return str } + // MARK: - Per-account balances + + /// Per-account balance snapshot read from Rust's in-memory state. + public struct AccountBalance { + public let typeTag: UInt8 + public let standardTag: UInt8 + public let index: UInt32 + public let registrationIndex: UInt32 + public let keyClass: UInt32 + public let userIdentityId: Data + public let friendIdentityId: Data + public let confirmed: UInt64 + public let unconfirmed: UInt64 + public let immature: UInt64 + public let locked: UInt64 + } + + /// Query per-account balances directly from the Rust-side + /// `WalletManager`'s in-memory state. No disk I/O — reads the + /// live `ManagedCoreAccount.balance` values maintained during SPV + /// processing. + public func accountBalances(for walletId: Data) -> [AccountBalance] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { + return [] + } + + var outEntries: UnsafePointer? + var outCount: UInt = 0 + var error = PlatformWalletFFIError() + + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return PLATFORM_WALLET_FFI_RESULT_ERROR_NULL_POINTER + } + return platform_wallet_manager_get_account_balances( + handle, + base, + &outEntries, + &outCount, + &error + ) + } + + guard result == PLATFORM_WALLET_FFI_RESULT_SUCCESS else { + self.lastError = PlatformWalletError(result: result, error: error) + return [] + } + + guard let entries = outEntries, outCount > 0 else { + return [] + } + + defer { + platform_wallet_manager_free_account_balances( + UnsafeMutablePointer(mutating: entries), + outCount + ) + } + + return (0.. some View { - // PlatformPayment balances are in credits (1e11/DASH), live on - // `PersistentPlatformAddress.balance`, and have no - // confirmed / pending split on-chain. if account.accountType == 14 { return AnyView(platformBalanceCard()) } + let balances = walletManager.accountBalances(for: wallet.walletId) + let match = balances.first { b in + UInt32(b.typeTag) == account.accountType && + b.standardTag == account.standardTag && + b.index == account.accountIndex + } + let confirmed = match?.confirmed ?? 0 + let unconfirmed = match?.unconfirmed ?? 0 + return AnyView(VStack(alignment: .leading, spacing: 12) { Label("Balance", systemImage: "bitcoinsign.circle.fill") .font(.headline) @@ -168,19 +175,19 @@ struct AccountDetailView: View { Text("Confirmed") .font(.caption) .foregroundColor(.secondary) - Text(formatBalance(account.balanceConfirmed)) + Text(formatBalance(confirmed)) .font(.title3) .fontWeight(.semibold) } Spacer() - if account.balanceUnconfirmed > 0 { + if unconfirmed > 0 { VStack(alignment: .trailing, spacing: 4) { Text("Pending") .font(.caption) .foregroundColor(.secondary) - Text(formatBalance(account.balanceUnconfirmed)) + Text(formatBalance(unconfirmed)) .font(.title3) .fontWeight(.semibold) .foregroundColor(.orange) @@ -195,7 +202,7 @@ struct AccountDetailView: View { .font(.caption) .foregroundColor(.secondary) Spacer() - Text(formatBalance(account.balanceConfirmed + account.balanceUnconfirmed)) + Text(formatBalance(confirmed + unconfirmed)) .font(.headline) .fontWeight(.bold) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index beccc377fb..8732d91423 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -5,29 +5,12 @@ import SwiftData // MARK: - Account List View struct AccountListView: View { let wallet: PersistentWallet - /// Wallet-scoped TXO set passed down from `WalletDetailView` so - /// we share a single `@Query` subscription across - /// `BalanceCardView` and the per-account rows. Without this - /// consolidation each consumer had its own subscription and the - /// persister stalled visibly during sync (3× SwiftData - /// change-tracking work per TXO insert). - let walletTxos: [PersistentTxo] + @EnvironmentObject var walletManager: PlatformWalletManager @Query private var accounts: [PersistentAccount] - /// `address → [TXO]` index built once per render. Each account - /// row asks for its slice via `txos(for:)`; that's a constant- - /// time set-of-lookups against the index instead of a fresh - /// O(walletTxos) filter per account. Address-pool size is - /// gap-limit-bounded (~30-60 rows), so this stays cheap even - /// for wallets with thousands of TXOs. - private var txosByAddress: [String: [PersistentTxo]] { - Dictionary(grouping: walletTxos, by: \.address) - } - - init(wallet: PersistentWallet, walletTxos: [PersistentTxo]) { + init(wallet: PersistentWallet) { self.wallet = wallet - self.walletTxos = walletTxos let walletId = wallet.walletId _accounts = Query( filter: #Predicate { acc in @@ -36,22 +19,6 @@ struct AccountListView: View { ) } - /// TXOs belonging to a specific account, looked up from the - /// pre-built `txosByAddress` index by walking the account's - /// address pool. - private func txos( - for account: PersistentAccount, - index: [String: [PersistentTxo]] - ) -> [PersistentTxo] { - var collected: [PersistentTxo] = [] - for address in account.coreAddresses { - if let bucket = index[address.address] { - collected.append(contentsOf: bucket) - } - } - return collected - } - /// Stable display order — grouped by logical priority rather /// than by raw `accountType` tag so BIP44 leads, PlatformPayment /// sits next, BIP32 follows, CoinJoin after, and every special- @@ -95,12 +62,18 @@ struct AccountListView: View { description: Text("Accounts are created automatically when the wallet syncs.") ) } else { - let index = txosByAddress + let balances = walletManager.accountBalances(for: wallet.walletId) List(orderedAccounts) { account in NavigationLink(destination: AccountDetailView(wallet: wallet, account: account)) { + let match = balances.first { b in + UInt32(b.typeTag) == account.accountType && + b.standardTag == account.standardTag && + b.index == account.accountIndex + } AccountRowView( account: account, - accountTxos: txos(for: account, index: index) + coreConfirmedBalance: match?.confirmed ?? 0, + coreUnconfirmedBalance: match?.unconfirmed ?? 0 ) } } @@ -113,29 +86,20 @@ struct AccountListView: View { // MARK: - Account Row View struct AccountRowView: View { let account: PersistentAccount - /// TXOs that the parent has identified as belonging to this - /// account. Pre-filtered upstream so the row doesn't have to - /// re-walk the wallet's TXO set per render. Empty for non- - /// Core-balance accounts (PlatformPayment / identity / etc.). - let accountTxos: [PersistentTxo] + /// Per-account confirmed balance queried from Rust's in-memory state. + let coreConfirmedBalance: UInt64 + /// Per-account unconfirmed balance queried from Rust's in-memory state. + let coreUnconfirmedBalance: UInt64 - /// Friendly label for the account. Indexed account types get a - /// trailing "#"; other types keep the bare name emitted by - /// the FFI (identity / provider / etc.). private var label: String { switch account.accountType { - case 0, 1, 14: // Standard (BIP44/BIP32), CoinJoin, PlatformPayment + case 0, 1, 14: return "\(account.accountTypeName) #\(account.accountIndex)" default: return account.accountTypeName } } - /// Whether this account should surface a numeric balance on the - /// summary row. Keyed on the FFI `accountType` tag — name - /// matching was fragile because the Rust side emits different - /// labels (e.g. "BIP44 Account" vs "Standard BIP44") across - /// releases. private var shouldShowBalance: Bool { switch account.accountType { case 0, 1, 14: return true @@ -143,32 +107,10 @@ struct AccountRowView: View { } } - /// PlatformPayment balance/unit differs from Core (credits, not - /// duffs) so the row needs a dedicated render path. `true` when - /// this row should use the `platformBalanceRow` helper. private var isPlatformPayment: Bool { account.accountType == 14 } - /// Per-account balance: partition the parent-supplied - /// `accountTxos` by `isSpent` × `isConfirmed`. - /// `PersistentAccount.balanceConfirmed` / `balanceUnconfirmed` - /// are persisted scalars but nothing currently writes them, so - /// we derive on read from the TXO set (the source of truth). - /// The walk happens upstream in `AccountListView.txos(for:)` — - /// this just filters the pre-narrowed slice. - private var coreConfirmedBalance: UInt64 { - accountTxos.lazy - .filter { !$0.isSpent && $0.isConfirmed } - .reduce(0) { $0 + $1.amount } - } - - private var coreUnconfirmedBalance: UInt64 { - accountTxos.lazy - .filter { !$0.isSpent && !$0.isConfirmed } - .reduce(0) { $0 + $1.amount } - } - private var iconName: String { switch account.accountType { case 0: diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index cc091a83f4..cce1735206 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -73,7 +73,7 @@ struct WalletDetailView: View { .padding(.top, 8) // Balance Card - BalanceCardView(wallet: wallet, walletTxos: walletTxos) + BalanceCardView(wallet: wallet) .padding() // Action Buttons @@ -151,7 +151,7 @@ struct WalletDetailView: View { .padding(.top) // Account List - AccountListView(wallet: wallet, walletTxos: walletTxos) + AccountListView(wallet: wallet) } .navigationTitle(wallet.label) .navigationBarTitleDisplayMode(.inline) @@ -580,43 +580,17 @@ struct WalletInfoView: View { struct BalanceCardView: View { let wallet: PersistentWallet + @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var shieldedService: ShieldedService @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService - /// Per-wallet platform-address balance rows. SwiftData drives - /// the sum directly so every wallet's card reflects its own - /// funds — the previous code read - /// `platformBalanceSyncService.totalPlatformBalance`, a - /// singleton tied to whichever wallet was most recently - /// configured, which caused every wallet's balance to show the - /// last-synced wallet's total. @Query private var addressBalances: [PersistentPlatformAddress] - /// Network-scoped BLAST sync watermark. One row per network — - /// shared across every wallet on that network — so this query - /// filters by `network` rather than `walletId`. Used only to - /// distinguish "synced with zero balance" from "never synced". @Query private var syncStates: [PersistentPlatformAddressesSyncState] - /// Per-wallet TXO rows passed down from `WalletDetailView` so we - /// share a single `@Query` subscription across - /// every child view that needs the balance. Originally each of - /// `BalanceCardView` / `AccountListView` / `WalletDetailView` - /// had its own subscription; during sync the SwiftData - /// change-tracking ran 3× per TXO insert and the persister - /// stalled visibly. One subscription, one walk. - let walletTxos: [PersistentTxo] - - init(wallet: PersistentWallet, walletTxos: [PersistentTxo]) { + init(wallet: PersistentWallet) { self.wallet = wallet - self.walletTxos = walletTxos let walletId = wallet.walletId - // `PersistentPlatformAddressesSyncState.network` is a required AppNetwork; - // `.testnet` is a harmless sentinel for wallets that haven't - // had their network stamped yet — they won't have a matching - // sync state row either, so the query naturally returns empty. - // Filter against `networkRaw` (the Int-backed shadow field) — - // Foundation's predicate engine can't capture `AppNetwork`. let walletNetworkRaw = (wallet.network ?? .testnet).rawValue _addressBalances = Query( filter: #Predicate { $0.walletId == walletId } @@ -626,20 +600,18 @@ struct BalanceCardView: View { ) } - /// Sum of unspent + confirmed TXO amounts. Walks the wallet-TXO - /// query result; one pass, one pred + add per row. + /// Confirmed core-chain balance summed from Rust's in-memory + /// per-account state via FFI. private var confirmedBalance: UInt64 { - walletTxos.lazy - .filter { !$0.isSpent && $0.isConfirmed } - .reduce(0) { $0 + $1.amount } + walletManager.accountBalances(for: wallet.walletId) + .reduce(0) { $0 + $1.confirmed } } - /// Sum of unspent + unconfirmed (mempool / IS-locked-but-not-in-block) - /// TXO amounts. + /// Unconfirmed core-chain balance summed from Rust's in-memory + /// per-account state via FFI. private var unconfirmedBalance: UInt64 { - walletTxos.lazy - .filter { !$0.isSpent && !$0.isConfirmed } - .reduce(0) { $0 + $1.amount } + walletManager.accountBalances(for: wallet.walletId) + .reduce(0) { $0 + $1.unconfirmed } } /// Platform balance from BLAST sync (preferred) or identity sum (fallback). diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift index d25f53f9c0..dc19df4b8c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift @@ -1,54 +1,78 @@ // WalletMemoryExplorerView.swift // SwiftExampleApp // -// Read-only diagnostic surface that surfaces the in-memory state of -// every loaded platform-wallet — the Rust-side `ManagedPlatformWallet` -// / `PlatformWalletInfo` / `IdentityManager`. Mirrors the spirit of -// `StorageExplorerView` (SwiftData) and `KeychainExplorerView` -// (Keychain), but the source of truth is `PlatformWalletManager -// .wallets`. +// Read-only diagnostic surface that mirrors the complete in-memory +// state of `PlatformWalletManager` — the Rust-side wallet manager, +// SPV runtime, platform-address sync manager, identity-token sync +// manager, and every loaded wallet's balance / identity / asset-lock +// state. // -// The whole point is "what does Rust hold *right now*" — every -// section is backed by a thin FFI enumerator on the Rust side and -// rendered as a flat list on the Swift side. No syncs, no edits, no -// background refreshes. Useful when SwiftData says an identity exists -// but the wallet's `IdentityManager` doesn't (the bug that motivated -// the view). +// Complements `StorageExplorerView` (SwiftData) and +// `KeychainExplorerView` (Keychain). Every section is backed by a +// thin FFI query or a `@Published` property on `PlatformWalletManager`. import SwiftUI import SwiftDashSDK // MARK: - Helpers -/// Truncated base58 view of a 32-byte identifier. Matches the -/// shorthand used elsewhere in the example app for compact lists. private func shortBase58(_ id: Identifier) -> String { let b58 = id.toBase58() - if b58.count <= 16 { - return b58 - } + if b58.count <= 16 { return b58 } return "\(b58.prefix(8))…\(b58.suffix(6))" } -/// Full base58 of a 32-byte identifier. private func fullBase58(_ id: Identifier) -> String { id.toBase58() } -/// Pretty label for a loaded wallet — prefers the SwiftData -/// `PersistentWallet.name`, falls back to the first 8 hex chars of -/// the wallet id. Kept private so each view file can have its own -/// fallback policy. private func walletDisplayLabel(_ walletId: Data, fromPersistent name: String?) -> String { - if let name, !name.isEmpty { - return name - } + if let name, !name.isEmpty { return name } let hex = walletId.prefix(4).map { String(format: "%02x", $0) }.joined() return hex.isEmpty ? "Unknown wallet" : "Wallet \(hex)…" } -/// One-line key-value row. Long values are middle-truncated and made -/// `textSelection`-able so users can copy ids from the explorer. +private func formatTimestamp(_ unix: UInt64) -> String { + guard unix > 0 else { return "never" } + let date = Date(timeIntervalSince1970: TimeInterval(unix)) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) +} + +private func formatDuffs(_ duffs: UInt64) -> String { + let dash = Double(duffs) / 100_000_000.0 + let f = NumberFormatter() + f.numberStyle = .decimal + f.minimumFractionDigits = 0 + f.maximumFractionDigits = 8 + return f.string(from: NSNumber(value: dash)).map { "\($0) DASH" } + ?? String(format: "%.8f DASH", dash) +} + +private func accountTypeName(typeTag: UInt8, standardTag: UInt8) -> String { + switch typeTag { + case 0: return standardTag == 0 ? "BIP44" : "BIP32" + case 1: return "CoinJoin" + case 2: return "IdentityRegistration" + case 3: return "IdentityTopUp" + case 4: return "IdentityTopUpUnbound" + case 5: return "IdentityInvitation" + case 6: return "AssetLockAddressTopUp" + case 7: return "AssetLockShieldedTopUp" + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + case 12: return "DashpayReceiving" + case 13: return "DashpayExternal" + case 14: return "PlatformPayment" + case 15: return "IdentityAuthECDSA" + case 16: return "IdentityAuthBLS" + default: return "Unknown(\(typeTag))" + } +} + private struct KVRow: View { let label: String let value: String @@ -66,97 +90,217 @@ private struct KVRow: View { } } -// MARK: - Top-level view: list of wallets +// MARK: - Top-level view struct WalletMemoryExplorerView: View { @EnvironmentObject var walletManager: PlatformWalletManager - @Environment(\.modelContext) private var modelContext + + @State private var addressSyncRunning = false + @State private var addressSyncing = false + @State private var addressSyncLastUnix: UInt64 = 0 + @State private var identitySyncRunning = false + @State private var identitySyncing = false + @State private var identityTokenRows: [IdentityTokenSyncRow] = [] + @State private var loadError: String? var body: some View { List { - if walletManager.wallets.isEmpty { + spvSection + addressSyncSection + identityTokenSyncSection + walletsSection + if let loadError { Section { - Text("No wallets currently loaded.") + Text(loadError) .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.red) + .textSelection(.enabled) } - } else { - Section { - ForEach(sortedWalletIds, id: \.self) { walletId in - if let wallet = walletManager.wallets[walletId] { - NavigationLink { - WalletMemoryDetailView( - wallet: wallet, - walletLabel: walletDisplayLabel(walletId, fromPersistent: nil) - ) - } label: { - walletRow(walletId: walletId, wallet: wallet) + } + } + .navigationTitle("Wallet Memory Explorer") + .navigationBarTitleDisplayMode(.inline) + .onAppear { loadManagerState() } + } + + // MARK: - SPV Sync + + private var spvSection: some View { + Section("SPV Sync") { + let p = walletManager.spvProgress + KVRow(label: "State", value: p.overallState.label) + KVRow(label: "Progress", value: String(format: "%.1f%%", p.overallPercentage)) + if let h = p.headers { + KVRow(label: "Headers", value: "\(h.currentHeight)/\(h.targetHeight)") + } + if let fh = p.filterHeaders { + KVRow(label: "Filter Headers", value: "\(fh.currentHeight)/\(fh.targetHeight)") + } + if let f = p.filters { + KVRow(label: "Filters", value: "\(f.currentHeight)/\(f.targetHeight)") + } + if let m = p.masternodes { + KVRow(label: "Masternodes", value: "\(m.currentHeight)/\(m.targetHeight)") + } + } + } + + // MARK: - Platform Address Sync + + private var addressSyncSection: some View { + Section("Platform Address Sync") { + KVRow(label: "Running", value: addressSyncRunning ? "yes" : "no") + KVRow(label: "Syncing", value: addressSyncing ? "yes" : "no") + KVRow(label: "Last Sync", value: formatTimestamp(addressSyncLastUnix)) + if let event = walletManager.lastPlatformAddressSyncEvent { + KVRow( + label: "Last Event", + value: "\(event.walletResults.count) wallet(s)" + ) + } + } + } + + // MARK: - Identity Token Sync + + private var identityTokenSyncSection: some View { + Section { + KVRow(label: "Running", value: identitySyncRunning ? "yes" : "no") + KVRow(label: "Syncing", value: identitySyncing ? "yes" : "no") + KVRow(label: "Cached Rows", value: "\(identityTokenRows.count)") + if !identityTokenRows.isEmpty { + let byIdentity = Dictionary(grouping: identityTokenRows, by: \.identityId) + ForEach(Array(byIdentity.keys.sorted(by: { $0.lexicographicallyPrecedes($1) })), id: \.self) { identityId in + let rows = byIdentity[identityId] ?? [] + DisclosureGroup { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 2) { + KVRow(label: "Token", value: shortBase58(row.tokenId)) + KVRow(label: "Balance", value: "\(row.balance)") + KVRow(label: "Nonce", value: "\(row.identityContractNonce)") } } + } label: { + Text(shortBase58(identityId)) + .font(.system(.caption, design: .monospaced)) } - } header: { - Text("Loaded Wallets") - } footer: { - Text( - "Read-only snapshot of what each wallet's " - + "Rust-side `IdentityManager` currently holds. " - + "Compare with the Storage Explorer to spot " - + "drift between SwiftData and the in-memory " - + "wallet state." - ) + } + } + } header: { + Text("Identity Token Sync") + } + } + + // MARK: - Wallets + + private var walletsSection: some View { + Section { + if walletManager.wallets.isEmpty { + Text("No wallets loaded.") .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(sortedWalletIds, id: \.self) { walletId in + if let wallet = walletManager.wallets[walletId] { + NavigationLink { + WalletMemoryDetailView( + wallet: wallet, + walletId: walletId, + walletLabel: walletDisplayLabel(walletId, fromPersistent: nil) + ) + } label: { + walletRow(walletId: walletId, wallet: wallet) + } + } } } + } header: { + Text("Wallets (\(walletManager.wallets.count))") } - .navigationTitle("Wallet Memory Explorer") - .navigationBarTitleDisplayMode(.inline) } - /// Stable display order — sort by raw walletId bytes so the list - /// position is deterministic across launches. private var sortedWalletIds: [Data] { - walletManager.wallets.keys.sorted { lhs, rhs in - lhs.lexicographicallyPrecedes(rhs) - } + walletManager.wallets.keys.sorted { $0.lexicographicallyPrecedes($1) } } @ViewBuilder private func walletRow(walletId: Data, wallet: ManagedPlatformWallet) -> some View { - // Pull the summary inline so the row shows the same counts - // the detail view confirms — keeps the diagnostic value of - // the listing front-loaded without an extra tap. let summary = (try? wallet.inMemorySummary()) ?? InMemoryWalletSummary( - identitiesCount: 0, - watchedCount: 0, - lastScannedIndex: 0, - primaryIdentityId: nil, - trackedAssetLocksCount: 0 + identitiesCount: 0, watchedCount: 0, lastScannedIndex: 0, + primaryIdentityId: nil, trackedAssetLocksCount: 0 ) + let bal = try? wallet.balance() VStack(alignment: .leading, spacing: 4) { Text(walletDisplayLabel(walletId, fromPersistent: nil)) .font(.headline) HStack(spacing: 4) { Text("\(summary.identitiesCount) identities") Text("·") - Text("\(summary.watchedCount) watched") - Text("·") Text("\(summary.trackedAssetLocksCount) asset locks") + if let bal { + Text("·") + Text(formatDuffs(bal.total)) + } } .font(.caption) .foregroundColor(.secondary) } .padding(.vertical, 2) } + + // MARK: - Load + + private func loadManagerState() { + loadError = nil + var errors: [String] = [] + do { + addressSyncRunning = try walletManager.isPlatformAddressSyncRunning() + } catch { + errors.append("Address sync running: \(error.localizedDescription)") + } + do { + addressSyncing = try walletManager.isPlatformAddressSyncing() + } catch { + errors.append("Address syncing: \(error.localizedDescription)") + } + do { + addressSyncLastUnix = try walletManager.lastPlatformAddressSyncUnixSeconds() + } catch { + errors.append("Address last sync: \(error.localizedDescription)") + } + do { + identitySyncRunning = try walletManager.isIdentityTokenSyncRunning() + } catch { + errors.append("Identity sync running: \(error.localizedDescription)") + } + do { + identitySyncing = try walletManager.isIdentityTokenSyncing() + } catch { + errors.append("Identity syncing: \(error.localizedDescription)") + } + do { + identityTokenRows = try walletManager.allIdentityTokenSyncRows() + } catch { + errors.append("Token sync state: \(error.localizedDescription)") + } + if !errors.isEmpty { + loadError = errors.joined(separator: "\n") + } + } } // MARK: - Per-wallet detail view struct WalletMemoryDetailView: View { let wallet: ManagedPlatformWallet + let walletId: Data let walletLabel: String + @EnvironmentObject var walletManager: PlatformWalletManager @State private var summary: InMemoryWalletSummary? @State private var summaryError: String? + @State private var walletBalance: ManagedPlatformWallet.WalletBalance? + @State private var accountBalances: [PlatformWalletManager.AccountBalance] = [] @State private var identityIds: [Identifier] = [] @State private var watchedIds: [Identifier] = [] @State private var idLabels: [Identifier: String] = [:] @@ -164,106 +308,179 @@ struct WalletMemoryDetailView: View { var body: some View { Form { - Section("Summary") { - if let summaryError { - Text(summaryError) + walletInfoSection + balanceSection + accountBalancesSection + summarySection + identitiesSection + watchedSection + if let loadError { + Section { + Text(loadError) .font(.caption) .foregroundColor(.red) - } else if let summary { - KVRow(label: "Identities", value: "\(summary.identitiesCount)") - KVRow(label: "Watched", value: "\(summary.watchedCount)") - KVRow(label: "Last Scanned Index", value: "\(summary.lastScannedIndex)") - KVRow( - label: "Tracked Asset Locks", - value: "\(summary.trackedAssetLocksCount)" - ) - if let primary = summary.primaryIdentityId { - KVRow(label: "Primary Identity", value: shortBase58(primary)) - Text(fullBase58(primary)) - .font(.caption2.monospaced()) - .foregroundColor(.secondary) - .textSelection(.enabled) - } else { - KVRow(label: "Primary Identity", value: "—") - } - } else { - HStack { - ProgressView().scaleEffect(0.8) - Text("Loading…").font(.caption).foregroundColor(.secondary) - } + .textSelection(.enabled) } } + } + .navigationTitle(walletLabel) + .navigationBarTitleDisplayMode(.inline) + .onAppear { loadOnce() } + } - Section { - if identityIds.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(identityIds, id: \.self) { id in - NavigationLink { - WalletMemoryIdentityDetailView(wallet: wallet, identityId: id) - } label: { - identityRow(id: id) - } - } - } - } header: { - HStack { - Text("Identities") - Spacer() - Text("\(identityIds.count)") - .font(.caption) - .foregroundColor(.secondary) - } - } footer: { - Text("Tap a row to dump its in-memory state.") - .font(.caption2) + // MARK: - Wallet Info + + private var walletInfoSection: some View { + Section("Wallet") { + KVRow( + label: "ID", + value: walletId.map { String(format: "%02x", $0) }.joined() + ) + } + } + + // MARK: - Balance + + private var balanceSection: some View { + Section("Wallet Balance") { + if let bal = walletBalance { + KVRow(label: "Confirmed", value: formatDuffs(bal.spendable)) + KVRow(label: "Unconfirmed", value: formatDuffs(bal.unconfirmed)) + KVRow(label: "Immature", value: formatDuffs(bal.immature)) + KVRow(label: "Locked", value: formatDuffs(bal.locked)) + KVRow(label: "Total", value: formatDuffs(bal.total)) + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) } + } + } - Section { - if watchedIds.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(watchedIds, id: \.self) { id in - VStack(alignment: .leading, spacing: 2) { - Text(shortBase58(id)) + // MARK: - Account Balances + + private var accountBalancesSection: some View { + Section { + if accountBalances.isEmpty { + Text("No accounts") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(accountBalances.enumerated()), id: \.offset) { _, acct in + let name = accountTypeName( + typeTag: acct.typeTag, + standardTag: acct.standardTag + ) + let total = acct.confirmed + acct.unconfirmed + + acct.immature + acct.locked + DisclosureGroup { + KVRow(label: "Confirmed", value: formatDuffs(acct.confirmed)) + KVRow(label: "Unconfirmed", value: formatDuffs(acct.unconfirmed)) + KVRow(label: "Immature", value: formatDuffs(acct.immature)) + KVRow(label: "Locked", value: formatDuffs(acct.locked)) + } label: { + HStack { + Text("\(name) #\(acct.index)") .font(.system(.body, design: .monospaced)) - Text(fullBase58(id)) - .font(.caption2.monospaced()) + Spacer() + Text(formatDuffs(total)) + .font(.caption) .foregroundColor(.secondary) - .textSelection(.enabled) } } } - } header: { + } + } header: { + Text("Core Account Balances (\(accountBalances.count))") + } + } + + // MARK: - Summary + + private var summarySection: some View { + Section("Identity Manager") { + if let summaryError { + Text(summaryError) + .font(.caption) + .foregroundColor(.red) + } else if let summary { + KVRow(label: "Identities", value: "\(summary.identitiesCount)") + KVRow(label: "Watched", value: "\(summary.watchedCount)") + KVRow(label: "Last Scanned Index", value: "\(summary.lastScannedIndex)") + KVRow( + label: "Tracked Asset Locks", + value: "\(summary.trackedAssetLocksCount)" + ) + } else { HStack { - Text("Watched Identities") - Spacer() - Text("\(watchedIds.count)") - .font(.caption) - .foregroundColor(.secondary) + ProgressView().scaleEffect(0.8) + Text("Loading…").font(.caption).foregroundColor(.secondary) } } + } + } - if let loadError { - Section { - Text(loadError) - .font(.caption) - .foregroundColor(.red) + // MARK: - Identities + + private var identitiesSection: some View { + Section { + if identityIds.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(identityIds, id: \.self) { id in + NavigationLink { + WalletMemoryIdentityDetailView(wallet: wallet, identityId: id) + } label: { + identityRow(id: id) + } } } + } header: { + HStack { + Text("Identities") + Spacer() + Text("\(identityIds.count)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Watched + + private var watchedSection: some View { + Section { + if watchedIds.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(watchedIds, id: \.self) { id in + VStack(alignment: .leading, spacing: 2) { + Text(shortBase58(id)) + .font(.system(.body, design: .monospaced)) + Text(fullBase58(id)) + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + } + } + } header: { + HStack { + Text("Watched Identities") + Spacer() + Text("\(watchedIds.count)") + .font(.caption) + .foregroundColor(.secondary) + } } - .navigationTitle(walletLabel) - .navigationBarTitleDisplayMode(.inline) - // Single one-shot load — the explorer is read-only, refreshing - // would mask the drift between SwiftData and in-memory state - // we are trying to surface. - .onAppear { loadOnce() } } + // MARK: - Helpers + @ViewBuilder private func identityRow(id: Identifier) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -285,34 +502,37 @@ struct WalletMemoryDetailView: View { } private func loadOnce() { - guard summary == nil else { return } + summaryError = nil + loadError = nil + var errors: [String] = [] do { summary = try wallet.inMemorySummary() } catch { summaryError = "Summary failed: \(error.localizedDescription)" } + walletBalance = try? wallet.balance() + accountBalances = walletManager.accountBalances(for: walletId) do { identityIds = try wallet.inMemoryIdentityIds() } catch { - loadError = "Identity list failed: \(error.localizedDescription)" + errors.append("Identity list failed: \(error.localizedDescription)") identityIds = [] } do { watchedIds = try wallet.inMemoryWatchedIdentityIds() } catch { - // Combined error message — same diagnostic surface either way. - loadError = "\(loadError ?? "")\nWatched list failed: " - + "\(error.localizedDescription)" + errors.append("Watched list failed: \(error.localizedDescription)") watchedIds = [] } - // Best-effort labels for the identity rows. Failures here are - // not fatal — the row still shows the truncated id. for id in identityIds { if let mi = try? wallet.managedIdentity(identityId: id), let label = try? mi.getLabel(), !label.isEmpty { idLabels[id] = label } } + if !errors.isEmpty { + loadError = errors.joined(separator: "\n") + } } } @@ -321,6 +541,7 @@ struct WalletMemoryDetailView: View { struct WalletMemoryIdentityDetailView: View { let wallet: ManagedPlatformWallet let identityId: Identifier + @EnvironmentObject var walletManager: PlatformWalletManager @State private var loaded = false @State private var loadError: String? @@ -341,168 +562,211 @@ struct WalletMemoryIdentityDetailView: View { @State private var dashpayProfile: DashPayProfile? @State private var dashpayProfileMissing: Bool = false + @State private var tokenSyncSnapshot: IdentityTokenSyncSnapshot? + var body: some View { Form { - Section("Identity") { - KVRow(label: "Id (short)", value: shortBase58(identityId)) - Text(fullBase58(identityId)) - .font(.caption2.monospaced()) - .foregroundColor(.secondary) - .textSelection(.enabled) - if loaded { - KVRow(label: "Label", value: label ?? "—") - KVRow(label: "Balance", value: "\(balance)") - KVRow(label: "Revision", value: "\(revision)") - KVRow( - label: "Identity Index", - value: identityIndex.map { "\($0)" } ?? "— (out of wallet)" - ) - KVRow(label: "Status", value: status.displayName) + identitySection + if loaded { + publicKeysSection + tokenSyncSection + sentRequestsSection + incomingRequestsSection + contactsSection + dpnsSection + contestedDpnsSection + dashpayProfileSection + } + if let loadError { + Section { + Text(loadError) + .font(.caption) + .foregroundColor(.red) + .textSelection(.enabled) } } + } + .navigationTitle("Identity") + .navigationBarTitleDisplayMode(.inline) + .onAppear { loadOnce() } + } + // MARK: - Identity + + private var identitySection: some View { + Section("Identity") { + KVRow(label: "Id (short)", value: shortBase58(identityId)) + Text(fullBase58(identityId)) + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + .textSelection(.enabled) if loaded { - Section { - if publicKeys.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(publicKeys, id: \.keyId) { key in - VStack(alignment: .leading, spacing: 2) { - Text("Key \(key.keyId)") - .font(.system(.body, design: .monospaced)) - Text( - "\(key.purpose.name) · \(key.securityLevel.name) · " - + "\(key.keyType.name)" - ) - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - } header: { - sectionHeader("Public Keys", count: publicKeys.count) - } + KVRow(label: "Label", value: label ?? "—") + KVRow(label: "Balance", value: "\(balance)") + KVRow(label: "Revision", value: "\(revision)") + KVRow( + label: "Identity Index", + value: identityIndex.map { "\($0)" } ?? "— (out of wallet)" + ) + KVRow(label: "Status", value: status.displayName) + } + } + } - Section { - if sentRequestIds.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(sentRequestIds, id: \.self) { id in - idRow(id) - } - } - } header: { - sectionHeader("Sent Contact Requests", count: sentRequestIds.count) - } + // MARK: - Public Keys - Section { - if incomingRequestIds.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(incomingRequestIds, id: \.self) { id in - idRow(id) - } + private var publicKeysSection: some View { + Section { + if publicKeys.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(publicKeys, id: \.keyId) { key in + VStack(alignment: .leading, spacing: 2) { + Text("Key \(key.keyId)") + .font(.system(.body, design: .monospaced)) + Text( + "\(key.purpose.name) · \(key.securityLevel.name) · " + + "\(key.keyType.name)" + ) + .font(.caption2) + .foregroundColor(.secondary) } - } header: { - sectionHeader( - "Incoming Contact Requests", - count: incomingRequestIds.count - ) } + } + } header: { + sectionHeader("Public Keys", count: publicKeys.count) + } + } - Section { - if establishedContactIds.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(establishedContactIds, id: \.self) { id in - idRow(id) + // MARK: - Token Sync State + + private var tokenSyncSection: some View { + Section { + if let snapshot = tokenSyncSnapshot { + KVRow( + label: "Last Sync", + value: formatTimestamp(snapshot.lastSyncUnixSeconds) + ) + if snapshot.rows.isEmpty { + Text("No tokens tracked") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(snapshot.rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 2) { + KVRow(label: "Token", value: shortBase58(row.tokenId)) + KVRow(label: "Balance", value: "\(row.balance)") + KVRow(label: "Nonce", value: "\(row.identityContractNonce)") } } - } header: { - sectionHeader( - "Established Contacts", - count: establishedContactIds.count - ) } + } else { + Text("No sync state cached") + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + let count = tokenSyncSnapshot?.rows.count ?? 0 + sectionHeader("Token Sync State", count: count) + } + } - Section { - if dpnsNames.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(dpnsNames, id: \.self) { name in - Text(name) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - } - } header: { - sectionHeader("DPNS Names", count: dpnsNames.count) - } + // MARK: - Contacts / Requests - Section { - if contestedDpnsNames.isEmpty { - Text("None") - .font(.caption) - .foregroundColor(.secondary) - } else { - ForEach(contestedDpnsNames, id: \.self) { name in - Text(name) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - } - } header: { - sectionHeader( - "Contested DPNS Names", - count: contestedDpnsNames.count - ) - } + private var sentRequestsSection: some View { + Section { + if sentRequestIds.isEmpty { + Text("None").font(.caption).foregroundColor(.secondary) + } else { + ForEach(sentRequestIds, id: \.self) { id in idRow(id) } + } + } header: { + sectionHeader("Sent Contact Requests", count: sentRequestIds.count) + } + } - Section("DashPay Profile") { - if let profile = dashpayProfile { - KVRow(label: "Display Name", value: profile.displayName ?? "—") - KVRow(label: "Public Message", value: profile.publicMessage ?? "—") - KVRow(label: "Avatar URL", value: profile.avatarUrl ?? "—") - } else if dashpayProfileMissing { - Text("No profile cached.") - .font(.caption) - .foregroundColor(.secondary) - } else { - // Profile read errored but we surface the - // overall error in the loadError section - // below; show a neutral placeholder here so - // section count stays stable. - Text("—") - .font(.caption) - .foregroundColor(.secondary) - } + private var incomingRequestsSection: some View { + Section { + if incomingRequestIds.isEmpty { + Text("None").font(.caption).foregroundColor(.secondary) + } else { + ForEach(incomingRequestIds, id: \.self) { id in idRow(id) } + } + } header: { + sectionHeader("Incoming Contact Requests", count: incomingRequestIds.count) + } + } + + private var contactsSection: some View { + Section { + if establishedContactIds.isEmpty { + Text("None").font(.caption).foregroundColor(.secondary) + } else { + ForEach(establishedContactIds, id: \.self) { id in idRow(id) } + } + } header: { + sectionHeader("Established Contacts", count: establishedContactIds.count) + } + } + + // MARK: - DPNS + + private var dpnsSection: some View { + Section { + if dpnsNames.isEmpty { + Text("None").font(.caption).foregroundColor(.secondary) + } else { + ForEach(dpnsNames, id: \.self) { name in + Text(name) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) } } + } header: { + sectionHeader("DPNS Names", count: dpnsNames.count) + } + } - if let loadError { - Section { - Text(loadError) - .font(.caption) - .foregroundColor(.red) + private var contestedDpnsSection: some View { + Section { + if contestedDpnsNames.isEmpty { + Text("None").font(.caption).foregroundColor(.secondary) + } else { + ForEach(contestedDpnsNames, id: \.self) { name in + Text(name) + .font(.system(.body, design: .monospaced)) .textSelection(.enabled) } } + } header: { + sectionHeader("Contested DPNS Names", count: contestedDpnsNames.count) + } + } + + // MARK: - DashPay Profile + + private var dashpayProfileSection: some View { + Section("DashPay Profile") { + if let profile = dashpayProfile { + KVRow(label: "Display Name", value: profile.displayName ?? "—") + KVRow(label: "Public Message", value: profile.publicMessage ?? "—") + KVRow(label: "Avatar URL", value: profile.avatarUrl ?? "—") + } else if dashpayProfileMissing { + Text("No profile cached.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("—") + .font(.caption) + .foregroundColor(.secondary) + } } - .navigationTitle("Identity") - .navigationBarTitleDisplayMode(.inline) - .onAppear { loadOnce() } } + // MARK: - Helpers + private func sectionHeader(_ title: String, count: Int) -> some View { HStack { Text(title) @@ -559,9 +823,23 @@ struct WalletMemoryIdentityDetailView: View { + error.localizedDescription ) } + tokenSyncSnapshot = try? walletManager.identityTokenSyncState(for: identityId) if !errors.isEmpty { loadError = errors.joined(separator: "\n") } loaded = true } } + +private extension PlatformSpvSyncState { + var label: String { + switch self { + case .waitForEvents: return "Waiting" + case .waitingForConnections: return "Connecting" + case .syncing: return "Syncing" + case .synced: return "Synced" + case .error: return "Error" + @unknown default: return "Unknown" + } + } +}