From ed5e30ade9c400060cd60c9af59b517498d233f3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 07:12:18 +0700 Subject: [PATCH 01/10] feat(swift-sdk): wallet memory explorer + persistor UTXO/sync load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `PlatformWalletManager` in the SwiftExampleApp explorer end-to-end and extends `load_from_persistor` to repopulate per-account UTXOs and core sync metadata so a restored wallet boots with non-zero balances. Memory explorer (read-only diagnostics): - 15 new `*_blocking` accessors on `PlatformWalletManager` covering the manager-level tunables (address-sync / identity-sync config, list of registered wallet ids), per-wallet state (core / identity / platform- address provider), per-wallet floating state (tracked asset locks, InstantSend lock txids), per-account drill-down (metadata, address pools with encoded address + derived public-key bytes, UTXOs), and the identity manager bucket layout. - Matching `#[repr(C)]` FFI surface in `manager_diagnostics.rs` with paired `_free` fns for every heap-owning shape. - Swift wrappers in `PlatformWalletManagerDiagnostics.swift`. - Explorer split into Funds vs Keys account sections with kind badges; Balance / UTXOs / Total UTXOs are suppressed on keys-only rows where they would always read zero by construction; address-pool rows now surface the encoded address plus derived public-key bytes. Persistor restore (`build_wallet_start_state` + `manager::load`): - `WalletRestoreEntryFFI` gains `birth_height`, `synced_height`, `last_processed_height`, `last_synced` (zero treated as "unknown" so `from_wallet`'s seeded default survives for fresh wallets), and a per-wallet `utxos: [UtxoRestoreEntryFFI]` array routed into the matching `ManagedCoreFundsAccount.utxos` map by `AccountType` tag. - After insertion the loader calls `wallet_info.update_balance()` and `manager::load_from_persistor` mirrors the recomputed inner balance into the lock-free `Arc` the UI reads (`WalletBalance::set` is `pub(crate)`). - Restored wallets are now `WalletType::ExternalSignable` instead of `WatchOnly` — signing requests route back through the host signer surface (Keychain-backed mnemonic), not error out. - Swift `loadWalletList` populates the new fields from `PersistentWallet` metadata and unspent `PersistentTxo` rows (`isSpent == false`, walking each row's `account` relationship for the routing tags). Upstream rust-dashcore alignment (no behavior change beyond compile fix): - Field-to-method renames on `ManagedAccountRef` (`managed_account_type`, `monitor_revision`); variant-aware reads via `as_funds()` for `balance` / `utxos`; `transactions_iter()` for the ref-enum tx walk (always empty when `keep_txs_in_memory` is off — tx history is event-driven). - `ManagedCoreAccount` → `ManagedCoreFundsAccount`; `accounts.insert(...)` → `accounts.insert_funds(...)` for DashPay funds-bearing accounts. - Drop the per-account `is_watch_only` and `custom_name` snapshots (upstream removed both fields). - Drop the standalone "PlatformWalletInfo Metadata" explorer section + its FFI / Swift wrapper / Rust accessor — every field either duplicated `CoreWalletStateSnapshot` or had no active populator. SwiftExampleApp cleanup: - `PersistentWallet` balance fields (`balanceConfirmed/Unconfirmed/ Immature/Locked`) are no longer surfaced — canonical source is `walletManager.accountBalances(for:)`. The persister callback no longer writes them either; `let b = cs.balance` removed. - Wallet-detail Total / breakdown logic and `SendTransactionView` switched to the in-memory account-balance source. - SPV progress label multiplies by 100 to render as a percent. Bumps rust-dashcore pin to `e2e8fcf8` on `v0.42-platform-nightly` for the `ManagedCoreFundsAccount` / `ManagedCoreKeysAccount` split and the upstream-removed `is_watch_only` / `first_loaded_at` / `total_transactions` fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 24 +- Cargo.toml | 18 +- .../src/core_wallet_types.rs | 166 +++- packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/manager_diagnostics.rs | 855 ++++++++++++++++++ .../rs-platform-wallet-ffi/src/persistence.rs | 166 +++- packages/rs-platform-wallet-ffi/src/wallet.rs | 4 +- .../src/wallet_restore_types.rs | 50 + .../src/changeset/core_bridge.rs | 8 +- .../src/manager/accessors.rs | 728 ++++++++++++++- .../src/manager/identity_sync.rs | 9 + .../rs-platform-wallet/src/manager/load.rs | 16 + .../rs-platform-wallet/src/manager/mod.rs | 2 +- .../src/manager/wallet_lifecycle.rs | 8 +- .../src/wallet/identity/network/contacts.rs | 17 +- .../src/wallet/platform_addresses/provider.rs | 35 + .../src/wallet/platform_addresses/wallet.rs | 10 + .../src/wallet/platform_wallet_traits.rs | 12 +- .../KeyWallet/ManagedAccount.swift | 9 +- .../Persistence/Models/PersistentWallet.swift | 19 +- .../PlatformWalletManager.swift | 13 +- .../PlatformWalletManagerDiagnostics.swift | 511 +++++++++++ .../PlatformWalletPersistenceHandler.swift | 132 ++- .../Core/Views/CoreContentView.swift | 52 +- .../Core/Views/ReceiveAddressView.swift | 19 +- .../Core/Views/SendTransactionView.swift | 7 +- .../Views/StorageRecordDetailViews.swift | 6 - .../Views/WalletMemoryExplorerView.swift | 663 +++++++++++++- 28 files changed, 3424 insertions(+), 137 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift diff --git a/Cargo.lock b/Cargo.lock index 60fa62c7549..ce0f8d78e0b 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" 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=ca507a92967ab4ab60dd681de1f736f8cc1d129f#ca507a92967ab4ab60dd681de1f736f8cc1d129f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e2e8fcf852130383b5922d3c2d907dda334296ee#e2e8fcf852130383b5922d3c2d907dda334296ee" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 0912c5d5b31..e262ef2aef4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,15 +49,15 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ca507a92967ab4ab60dd681de1f736f8cc1d129f" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e2e8fcf852130383b5922d3c2d907dda334296ee" } # 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 3da3aad797e..127f67f845f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -362,8 +362,12 @@ 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`. +/// same `AccountTypeTagFFI` discriminants as `AccountSpecFFI`, the four +/// balance fields from `WalletCoreBalance`, and address-pool key-usage +/// totals (`keys_used` / `keys_total`) summed across every pool on the +/// account. The pool counts are meaningful for both funds and keys +/// variants; the explorer surfaces them as the headline number on +/// keys-only rows where balance reads zero by construction. #[repr(C)] pub struct AccountBalanceEntryFFI { pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, @@ -377,6 +381,164 @@ pub struct AccountBalanceEntryFFI { pub unconfirmed: u64, pub immature: u64, pub locked: u64, + pub keys_used: u32, + pub keys_total: u32, +} + +// --------------------------------------------------------------------------- +// Diagnostic snapshot FFI types +// --------------------------------------------------------------------------- +// +// All structs here are read-only diagnostic surfaces consumed by the +// iOS memory explorer. Each struct mirrors a `*Snapshot` type in +// `platform-wallet`'s `manager::accessors` module 1:1. + +/// Snapshot of [`PlatformAddressSyncManager`] configuration / counters. +#[repr(C)] +pub struct PlatformAddressSyncConfigFFI { + pub interval_seconds: u64, + pub watch_list_size: usize, + pub last_event_wallet_count: u32, + pub last_event_unix_seconds: u64, +} + +/// Snapshot of [`IdentitySyncManager`] configuration / queue depth. +#[repr(C)] +pub struct IdentitySyncConfigFFI { + pub interval_seconds: u64, + pub queue_depth: usize, +} + +/// Per-wallet core SPV state. +#[repr(C)] +pub struct CoreWalletStateFFI { + pub synced_height: u32, + pub last_processed_height: u32, + pub monitor_revision: u64, +} + +/// Per-wallet identity scan state. +#[repr(C)] +pub struct IdentityWalletStateFFI { + pub last_scanned_index: u32, + pub scan_pending: bool, +} + +/// Per-wallet platform address provider state. +#[repr(C)] +pub struct PlatformAddressProviderStateFFI { + pub initialized: bool, + pub accounts_watched: usize, + pub found_count: usize, + pub known_balances_count: usize, + pub watermark_height: u32, +} + +// `WalletInfoMetadataFFI` was removed in lockstep with the explorer's +// "PlatformWalletInfo Metadata" section — every meaningful field +// duplicated `CoreWalletStateFFI` or had nothing populating it. + +/// One row of the tracked-asset-lock list. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct TrackedAssetLockEntryFFI { + pub outpoint_txid: [u8; 32], + pub outpoint_vout: u32, + /// 0=IdentityRegistration, 1=IdentityTopUp, 2=IdentityTopUpNotBound, + /// 3=IdentityInvitation, 4=AssetLockAddressTopUp, + /// 5=AssetLockShieldedAddressTopUp. + pub lock_type: u8, + /// 0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked. + pub status: u8, + pub registration_index: u32, + pub instant_lock_present: bool, + pub chain_lock_height: u32, +} + +/// Snapshot of the per-account metadata for one account. Strings are +/// Per-account metadata snapshot. +/// +/// `is_watch_only` and `custom_name` were dropped in lockstep with +/// upstream removing both fields from `ManagedCoreFundsAccount` / +/// `ManagedCoreKeysAccount`. Watch-only is now wallet-level (read off +/// `Wallet.wallet_type`); `AccountMetadata` no longer exists. The +/// struct is now plain-data — no heap-owned fields, no paired free fn +/// strictly required (kept as a stable no-op). +#[repr(C)] +pub struct AccountMetadataFFI { + pub total_transactions: u64, + pub total_utxos: u64, + pub monitor_revision: u64, +} + +/// One address row inside [`AccountAddressPoolEntryFFI`]. The pool's +/// own free fn walks the nested array and reclaims it. +/// +/// `address` is a heap-owned NUL-terminated UTF-8 string; +/// `public_key_bytes` is a heap-owned byte buffer (`null` + +/// `public_key_bytes_len = 0` when the pool entry didn't retain the +/// derivation source). Both are freed by the parent pool's free fn. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AddressInfoFFI { + pub pubkey_hash: [u8; 20], + pub address_index: u32, + pub is_used: bool, + /// `last_used_height` is reserved on the FFI — upstream + /// `AddressInfo` doesn't currently track per-address height. Set + /// to `0`; will be populated when upstream gains the field. + pub last_used_height: u32, + pub address: *mut c_char, + pub public_key_bytes: *mut u8, + pub public_key_bytes_len: usize, +} + +/// One pool-level entry inside the per-account address pool snapshot. +/// `addresses` is a heap-owned slice of `AddressInfoFFI`, freed by the +/// paired free fn (which walks every pool first). +#[repr(C)] +pub struct AccountAddressPoolEntryFFI { + /// 0=External, 1=Internal, 2=Absent, 3=AbsentHardened. + pub pool_type: u8, + pub gap_limit: u32, + /// `i64`-encoded; `-1` signals "no addresses used yet". + pub last_used_index: i64, + pub addresses: *mut AddressInfoFFI, + pub addresses_count: usize, +} + +/// One UTXO row in the per-account drill-down. `script_pubkey` is +/// heap-owned and freed by the paired free fn. +#[repr(C)] +pub struct AccountUtxoEntryFFI { + pub outpoint_txid: [u8; 32], + pub outpoint_vout: u32, + pub value_duffs: u64, + pub script_pubkey: *mut u8, + pub script_pubkey_len: usize, + pub height: u32, + pub is_locked: bool, +} + +/// One transaction row in the per-account paginated drill-down. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AccountTransactionEntryFFI { + pub txid: [u8; 32], + pub height: u32, + pub timestamp: u64, + pub value_delta_duffs: i64, + pub fee_duffs: u64, + pub is_coinbase: bool, +} + +/// One row of the wallet-bound identity list (registration index + +/// identity id). +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WalletIdentityRowFFI { + pub registration_index: u32, + pub identity_id: [u8; 32], } /// Subset of [`crate::wallet_restore_types::AccountSpecFFI`] carrying diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index c0f3123b530..65c5500325f 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -43,6 +43,7 @@ pub mod identity_update; pub mod identity_withdrawal; pub mod managed_identity; pub mod manager; +pub mod manager_diagnostics; pub mod memory_explorer; pub mod persistence; pub mod platform_address_sync; @@ -95,6 +96,7 @@ pub use identity_update::*; pub use identity_withdrawal::*; pub use managed_identity::*; pub use manager::*; +pub use manager_diagnostics::*; pub use memory_explorer::*; pub use persistence::*; pub use platform_address_sync::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs new file mode 100644 index 00000000000..84c6f95b6bf --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -0,0 +1,855 @@ +//! Read-only diagnostic accessors mirroring `PlatformWalletManager`'s +//! `*_blocking` snapshot methods on the FFI surface. +//! +//! Every fn here follows the same pattern as +//! [`crate::wallet::platform_wallet_manager_get_account_balances`]: +//! validate pointers, read the snapshot via the paired +//! `_blocking` accessor on `PlatformWalletManager`, marshal into a +//! heap-owned `[T]`, and hand back `(*const T, count)` via out-params. +//! Each non-trivial allocation has a paired `*_free` fn so the Swift +//! caller can reclaim it. +//! +//! These are bridges, not policy. No iteration, no decision logic — +//! the snapshot logic lives upstream on +//! [`platform_wallet::manager::accessors`]. + +use std::ffi::CString; +use std::os::raw::c_char; + +use platform_wallet::manager::accessors::{ + AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, + AccountTransactionSnapshot, AccountUtxoSnapshot, CoreWalletStateSnapshot, + IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, PlatformAddressProviderStateSnapshot, + PlatformAddressSyncConfigSnapshot, TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, +}; + +use crate::check_ptr; +use crate::core_wallet_types::{ + AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, + AccountUtxoEntryFFI, AddressInfoFFI, CoreWalletStateFFI, IdentitySyncConfigFFI, + IdentityWalletStateFFI, PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, + TrackedAssetLockEntryFFI, WalletIdentityRowFFI, +}; +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; +use crate::wallet_restore_types::AccountSpecFFI; + +// --------------------------------------------------------------------------- +// Phase 2 — Manager-level diagnostic snapshots +// --------------------------------------------------------------------------- + +/// Atomic snapshot of every wallet id currently registered on the +/// manager. Wallet ids are written as a flat `count * 32`-byte buffer. +/// Caller frees via [`platform_wallet_manager_free_wallet_ids`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_list_wallet_ids( + manager_handle: Handle, + out_bytes: *mut *const u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_bytes); + check_ptr!(out_count); + *out_bytes = std::ptr::null(); + *out_count = 0; + + let Some(ids) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.list_wallet_ids_blocking()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if ids.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let count = ids.len(); + let mut flat: Vec = Vec::with_capacity(count * 32); + for id in &ids { + flat.extend_from_slice(id); + } + let boxed = flat.into_boxed_slice(); + *out_bytes = Box::into_raw(boxed) as *const u8; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_wallet_ids(bytes: *mut u8, count: usize) { + if !bytes.is_null() && count > 0 { + let total = count * 32; + drop(Vec::from_raw_parts(bytes, total, total)); + } +} + +/// Snapshot of the platform-address sync coordinator's config / +/// counters. Single-struct, no allocation — the result is written +/// in-place into `out_state`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_config( + manager_handle: Handle, + out_state: *mut PlatformAddressSyncConfigFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_state); + let Some(snap): Option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.platform_address_sync_config_blocking() + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + *out_state = PlatformAddressSyncConfigFFI { + interval_seconds: snap.interval_seconds, + watch_list_size: snap.watch_list_size, + last_event_wallet_count: snap.last_event_wallet_count, + last_event_unix_seconds: snap.last_event_unix_seconds, + }; + PlatformWalletFFIResult::ok() +} + +/// Snapshot of the identity sync coordinator's config / queue depth. +/// Single-struct, no allocation. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_identity_sync_config( + manager_handle: Handle, + out_state: *mut IdentitySyncConfigFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_state); + let Some(snap): Option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.identity_sync_config_blocking()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + *out_state = IdentitySyncConfigFFI { + interval_seconds: snap.interval_seconds, + queue_depth: snap.queue_depth, + }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Phase 3 — Per-wallet state +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_core_wallet_state( + manager_handle: Handle, + wallet_id: *const u8, + out_state: *mut CoreWalletStateFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_state); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(snap_opt): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.core_wallet_state_blocking(&wid)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let Some(snap) = snap_opt else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found".to_string(), + ); + }; + *out_state = CoreWalletStateFFI { + synced_height: snap.synced_height, + last_processed_height: snap.last_processed_height, + monitor_revision: snap.monitor_revision, + }; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_wallet_state( + manager_handle: Handle, + wallet_id: *const u8, + out_state: *mut IdentityWalletStateFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_state); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(snap_opt): Option> = + PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.identity_wallet_state_blocking(&wid)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let Some(snap) = snap_opt else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found".to_string(), + ); + }; + *out_state = IdentityWalletStateFFI { + last_scanned_index: snap.last_scanned_index, + scan_pending: snap.scan_pending, + }; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_platform_address_provider_state( + manager_handle: Handle, + wallet_id: *const u8, + out_state: *mut PlatformAddressProviderStateFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_state); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(snap_opt): Option> = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.platform_address_provider_state_blocking(&wid) + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let Some(snap) = snap_opt else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found".to_string(), + ); + }; + *out_state = PlatformAddressProviderStateFFI { + initialized: snap.initialized, + accounts_watched: snap.accounts_watched, + found_count: snap.found_count, + known_balances_count: snap.known_balances_count, + watermark_height: snap.watermark_height, + }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Phase 4 — Wallet metadata + floating state +// --------------------------------------------------------------------------- + +// `platform_wallet_info_metadata` + `_free` were removed: every field +// they exposed (name, description, birth_height, synced_height, +// last_processed_height, total_transactions, first_loaded_at) was +// either duplicated by `platform_wallet_core_wallet_state` or had no +// active populator on this path. Re-add the surface only if a future +// caller needs name/description specifically. + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_tracked_asset_locks_list( + manager_handle: Handle, + wallet_id: *const u8, + out_entries: *mut *const TrackedAssetLockEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_entries); + check_ptr!(out_count); + *out_entries = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(rows): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.tracked_asset_locks_blocking(&wid)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|s| TrackedAssetLockEntryFFI { + outpoint_txid: txid_to_array(&s.outpoint.txid), + outpoint_vout: s.outpoint.vout, + lock_type: s.lock_type, + status: s.status, + registration_index: s.registration_index, + instant_lock_present: s.instant_lock_present, + chain_lock_height: s.chain_lock_height, + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_entries = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_tracked_asset_locks_free( + entries: *mut TrackedAssetLockEntryFFI, + count: usize, +) { + if !entries.is_null() && count > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_instant_send_locks( + manager_handle: Handle, + wallet_id: *const u8, + out_bytes: *mut *const u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_bytes); + check_ptr!(out_count); + *out_bytes = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(txids) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.instant_send_locks_blocking(&wid)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if txids.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let count = txids.len(); + let mut flat: Vec = Vec::with_capacity(count * 32); + for tx in &txids { + flat.extend_from_slice(tx.as_ref()); + } + let boxed = flat.into_boxed_slice(); + *out_bytes = Box::into_raw(boxed) as *const u8; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_instant_send_locks_free(bytes: *mut u8, count: usize) { + if !bytes.is_null() && count > 0 { + let total = count * 32; + drop(Vec::from_raw_parts(bytes, total, total)); + } +} + +// --------------------------------------------------------------------------- +// Phase 5 — Per-account drill-down +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_metadata( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_meta: *mut AccountMetadataFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_meta); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(snap_opt): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.account_metadata_blocking(&wid, &target) + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let Some(snap) = snap_opt else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Account not found".to_string(), + ); + }; + *out_meta = AccountMetadataFFI { + total_transactions: snap.total_transactions, + total_utxos: snap.total_utxos, + monitor_revision: snap.monitor_revision, + }; + PlatformWalletFFIResult::ok() +} + +/// Stable no-op — `AccountMetadataFFI` no longer carries heap-owned +/// fields after `is_watch_only` / `custom_name` were dropped. Kept on +/// the FFI surface so the Swift caller doesn't need to special-case +/// the freed-by-caller idiom on a single struct. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_metadata_free(_meta: *mut AccountMetadataFFI) {} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_address_pools( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_pools: *mut *const AccountAddressPoolEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_pools); + check_ptr!(out_count); + *out_pools = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(pools): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.account_address_pools_blocking(&wid, &target) + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if pools.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = pools + .into_iter() + .map(|pool| { + let addr_count = pool.addresses.len(); + let addrs: Vec = pool + .addresses + .into_iter() + .map(|a: AccountAddressInfoSnapshot| { + // Heap-allocate the address string + pubkey bytes; + // freed by `platform_wallet_account_address_pools_free`. + let address = optional_into_raw(Some(a.address)); + let (pk_ptr, pk_len) = if a.public_key_bytes.is_empty() { + (std::ptr::null_mut(), 0usize) + } else { + let len = a.public_key_bytes.len(); + let boxed = a.public_key_bytes.into_boxed_slice(); + (Box::into_raw(boxed) as *mut u8, len) + }; + AddressInfoFFI { + pubkey_hash: a.pubkey_hash, + address_index: a.address_index, + is_used: a.is_used, + last_used_height: 0, + address, + public_key_bytes: pk_ptr, + public_key_bytes_len: pk_len, + } + }) + .collect(); + let addresses_ptr = if addr_count == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(addrs.into_boxed_slice()) as *mut AddressInfoFFI + }; + AccountAddressPoolEntryFFI { + pool_type: pool.pool_type, + gap_limit: pool.gap_limit, + last_used_index: pool.last_used_index, + addresses: addresses_ptr, + addresses_count: addr_count, + } + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_pools = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_address_pools_free( + pools: *mut AccountAddressPoolEntryFFI, + count: usize, +) { + if pools.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts(pools, count); + for entry in slice { + if !entry.addresses.is_null() && entry.addresses_count > 0 { + // Walk every per-address row first to release its + // heap-owned `address` C string and `public_key_bytes` + // buffer before the parent slice is reclaimed. + let addrs = + std::slice::from_raw_parts(entry.addresses, entry.addresses_count); + for a in addrs { + if !a.address.is_null() { + let _ = CString::from_raw(a.address); + } + if !a.public_key_bytes.is_null() && a.public_key_bytes_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + a.public_key_bytes, + a.public_key_bytes_len, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.addresses, + entry.addresses_count, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(pools, count)); +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_utxos( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_utxos: *mut *const AccountUtxoEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_utxos); + check_ptr!(out_count); + *out_utxos = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(rows): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.account_utxos_blocking(&wid, &target)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|s| { + let script_len = s.script_pubkey.len(); + let script_ptr = if script_len == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(s.script_pubkey.into_boxed_slice()) as *mut u8 + }; + AccountUtxoEntryFFI { + outpoint_txid: txid_to_array(&s.outpoint.txid), + outpoint_vout: s.outpoint.vout, + value_duffs: s.value_duffs, + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height: s.height, + is_locked: s.is_locked, + } + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_utxos = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_utxos_free( + utxos: *mut AccountUtxoEntryFFI, + count: usize, +) { + if utxos.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts(utxos, count); + for entry in slice { + if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { + drop(Vec::from_raw_parts( + entry.script_pubkey, + entry.script_pubkey_len, + entry.script_pubkey_len, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); +} + +// --------------------------------------------------------------------------- +// Phase 6 — Per-account transactions +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_transactions( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + page_offset: usize, + page_limit: usize, + out_txs: *mut *const AccountTransactionEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_txs); + check_ptr!(out_count); + *out_txs = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(rows): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.account_transactions_blocking(&wid, &target, page_offset, page_limit) + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|s| AccountTransactionEntryFFI { + txid: txid_to_array(&s.txid), + height: s.height, + timestamp: s.timestamp, + value_delta_duffs: s.value_delta_duffs, + fee_duffs: s.fee_duffs, + is_coinbase: s.is_coinbase, + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_txs = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_transactions_free( + txs: *mut AccountTransactionEntryFFI, + count: usize, +) { + if !txs.is_null() && count > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(txs, count)); + } +} + +// --------------------------------------------------------------------------- +// Phase 7 — Identity manager structure +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_manager_out_of_wallet_ids( + manager_handle: Handle, + wallet_id: *const u8, + out_bytes: *mut *const u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_bytes); + check_ptr!(out_count); + *out_bytes = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(ids) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.identity_manager_out_of_wallet_ids_blocking(&wid) + }) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if ids.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let count = ids.len(); + let mut flat: Vec = Vec::with_capacity(count * 32); + for id in &ids { + flat.extend_from_slice(&id.to_buffer()); + } + let boxed = flat.into_boxed_slice(); + *out_bytes = Box::into_raw(boxed) as *const u8; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_manager_out_of_wallet_ids_free( + bytes: *mut u8, + count: usize, +) { + if !bytes.is_null() && count > 0 { + let total = count * 32; + drop(Vec::from_raw_parts(bytes, total, total)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_manager_wallet_identities( + manager_handle: Handle, + wallet_id: *const u8, + out_rows: *mut *const WalletIdentityRowFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_rows); + check_ptr!(out_count); + *out_rows = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(rows): Option> = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.identity_manager_wallet_identities_blocking(&wid) + }) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|r| WalletIdentityRowFFI { + registration_index: r.registration_index, + identity_id: r.identity_id, + }) + .collect(); + let count = entries.len(); + let boxed = entries.into_boxed_slice(); + *out_rows = Box::into_raw(boxed) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_manager_wallet_identities_free( + rows: *mut WalletIdentityRowFFI, + count: usize, +) { + if !rows.is_null() && count > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn txid_to_array(txid: &dashcore::Txid) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(txid.as_ref()); + out +} + +fn optional_into_raw(s: Option) -> *mut c_char { + match s { + Some(string) => match CString::new(string) { + Ok(c) => c.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + None => std::ptr::null_mut(), + } +} + +/// Project an [`AccountSpecFFI`] (without xpub) into the canonical +/// [`key_wallet::account::AccountType`] used by the snapshot +/// accessors. Mirrors `account_type_from_spec` in `persistence.rs` +/// without requiring the xpub field. +fn account_type_from_spec_ref( + spec: &AccountSpecFFI, +) -> Result { + use crate::wallet_restore_types::{AccountTypeTagFFI, StandardAccountTypeTagFFI}; + use key_wallet::account::{AccountType, StandardAccountType}; + Ok(match spec.type_tag { + AccountTypeTagFFI::Standard => { + let standard_account_type = match spec.standard_tag { + StandardAccountTypeTagFFI::Bip44 => StandardAccountType::BIP44Account, + StandardAccountTypeTagFFI::Bip32 => StandardAccountType::BIP32Account, + }; + AccountType::Standard { + index: spec.index, + standard_account_type, + } + } + AccountTypeTagFFI::CoinJoin => AccountType::CoinJoin { index: spec.index }, + AccountTypeTagFFI::IdentityRegistration => AccountType::IdentityRegistration, + AccountTypeTagFFI::IdentityTopUp => AccountType::IdentityTopUp { + registration_index: spec.registration_index, + }, + AccountTypeTagFFI::IdentityTopUpNotBoundToIdentity => { + AccountType::IdentityTopUpNotBoundToIdentity + } + AccountTypeTagFFI::IdentityInvitation => AccountType::IdentityInvitation, + AccountTypeTagFFI::AssetLockAddressTopUp => AccountType::AssetLockAddressTopUp, + AccountTypeTagFFI::AssetLockShieldedAddressTopUp => { + AccountType::AssetLockShieldedAddressTopUp + } + AccountTypeTagFFI::ProviderVotingKeys => AccountType::ProviderVotingKeys, + AccountTypeTagFFI::ProviderOwnerKeys => AccountType::ProviderOwnerKeys, + AccountTypeTagFFI::ProviderOperatorKeys => AccountType::ProviderOperatorKeys, + AccountTypeTagFFI::ProviderPlatformKeys => AccountType::ProviderPlatformKeys, + AccountTypeTagFFI::DashpayReceivingFunds => AccountType::DashpayReceivingFunds { + index: spec.index, + user_identity_id: spec.user_identity_id, + friend_identity_id: spec.friend_identity_id, + }, + AccountTypeTagFFI::DashpayExternalAccount => AccountType::DashpayExternalAccount { + index: spec.index, + user_identity_id: spec.user_identity_id, + friend_identity_id: spec.friend_identity_id, + }, + AccountTypeTagFFI::PlatformPayment => AccountType::PlatformPayment { + account: spec.index, + key_class: spec.key_class, + }, + AccountTypeTagFFI::IdentityAuthenticationEcdsa + | AccountTypeTagFFI::IdentityAuthenticationBls => { + return Err(format!( + "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType", + spec.type_tag + )); + } + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 0e1b98ecf08..3ee77af7604 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -10,6 +10,7 @@ use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::ExtendedPubKey; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::AddressInfo; @@ -41,7 +42,8 @@ use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI}; use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - LoadWalletListFreeFn, StandardAccountTypeTagFFI, WalletRestoreEntryFFI, + LoadWalletListFreeFn, StandardAccountTypeTagFFI, UtxoRestoreEntryFFI, + WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -1178,13 +1180,163 @@ fn build_wallet_start_state( .map_err(|e| format!("AccountCollection::insert failed: {}", e))?; } - // Watch-only wallet via the new unit-variant constructor — takes - // the wallet_id directly (no recomputation from a root xpub we - // don't store anymore). Signing ops error out until a follow-up - // unlock path builds a signing wallet from the mnemonic. - let wallet = Wallet::new_watch_only(network, entry.wallet_id, accounts); + // External-signable wallet — the mnemonic / seed lives in the + // iOS Keychain, not in this Rust handle. Signing requests route + // back to the host through the configured signer surface; the + // host fetches the mnemonic from the Keychain on demand. The + // wallet_id is passed in directly (no recomputation from a root + // xpub the snapshot doesn't carry). + let wallet = Wallet::new_external_signable(network, entry.wallet_id, accounts); + + // Stamp the persisted core-chain sync metadata onto the rebuilt + // managed-info. `from_wallet` seeds `synced_height` and + // `last_processed_height` to `birth_height - 1`; we then override + // with the values Swift actually persisted, treating zero as + // "unknown" so we don't clobber the seeded default for fresh / + // never-synced wallets. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, entry.birth_height); + if entry.synced_height > 0 { + wallet_info.metadata.synced_height = entry.synced_height; + } + if entry.last_processed_height > 0 { + wallet_info.metadata.last_processed_height = entry.last_processed_height; + } + if entry.last_synced > 0 { + wallet_info.metadata.last_synced = Some(entry.last_synced); + } + + // Persisted unspent UTXOs → funds-bearing accounts. Keys-only and + // PlatformPayment variants are skipped: the former never carry + // UTXOs, the latter route through `PlatformAddressSyncStartState`. + // Each row is mapped from `(prev_txid, vout, script_pubkey, + // value, height, flags)` into the target account's `utxos` map. + let utxo_entries: &[UtxoRestoreEntryFFI] = if entry.utxos.is_null() || entry.utxos_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(entry.utxos, entry.utxos_count) } + }; + let mut routed = 0usize; + for u in utxo_entries { + // Bring `Hash` into scope locally so `Txid::from_slice` is + // available — matches the pattern used elsewhere in this + // crate (see e.g. asset_lock/sync.rs). + use dashcore::hashes::Hash; + let script_bytes = unsafe { slice_from_raw(u.script_pubkey, u.script_pubkey_len) }; + // Build the AccountType via the same helper the per-spec path + // uses, repackaged as an `AccountSpecFFI` so we can reuse + // `account_type_from_spec` (it ignores `account_xpub_bytes`). + let spec = AccountSpecFFI { + type_tag: u.type_tag, + standard_tag: u.standard_tag, + index: u.account_index, + registration_index: u.registration_index, + key_class: u.key_class, + user_identity_id: u.user_identity_id, + friend_identity_id: u.friend_identity_id, + account_xpub_bytes: std::ptr::null(), + account_xpub_bytes_len: 0, + }; + // Tags that don't map to any current `AccountType` (e.g. + // legacy `IdentityAuthentication{Ecdsa,Bls}`) are silently + // skipped — the SwiftData row can't be restored cleanly and + // the next sync will recover any funds it represents. + let Ok(account_type) = account_type_from_spec(&spec) else { + continue; + }; + let Ok(txid) = dashcore::Txid::from_slice(&u.prev_txid) else { + continue; + }; + let outpoint = dashcore::OutPoint { + txid, + vout: u.vout, + }; + let script_pubkey = dashcore::ScriptBuf::from_bytes(script_bytes.to_vec()); + let Ok(address) = dashcore::Address::from_script(&script_pubkey, network) else { + continue; + }; + let txout = dashcore::TxOut { + value: u.value_duffs, + script_pubkey, + }; + let utxo = key_wallet::Utxo { + outpoint, + txout, + address, + height: u.height, + is_coinbase: u.is_coinbase, + is_confirmed: u.is_confirmed, + is_instantlocked: u.is_instantlocked, + is_locked: u.is_locked, + // `is_trusted` is a runtime-only flag derived from the + // tx graph (we created it ourselves and it pays back to + // us). Recompute on the next SPV pass; default to false. + is_trusted: false, + }; + // Route into the target funds-bearing account. Match on the + // resolved `AccountType` and look up the right map field. Keys + // and Platform variants are intentionally no-ops. + let target_funds = match account_type { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } => wallet_info.accounts.standard_bip44_accounts.get_mut(&index), + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP32Account, + } => wallet_info.accounts.standard_bip32_accounts.get_mut(&index), + AccountType::CoinJoin { index } => { + wallet_info.accounts.coinjoin_accounts.get_mut(&index) + } + AccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + } => wallet_info + .accounts + .dashpay_receival_accounts + .get_mut(&key_wallet::account::account_collection::DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::DashpayExternalAccount { + index, + user_identity_id, + friend_identity_id, + } => wallet_info + .accounts + .dashpay_external_accounts + .get_mut(&key_wallet::account::account_collection::DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + _ => None, + }; + if let Some(funds_account) = target_funds { + funds_account.utxos.insert(utxo.outpoint, utxo); + routed += 1; + } + // Snapshot drift (UTXO references an account that didn't + // make it into `entry.accounts`, or the account is keys-only + // / PlatformPayment) is silently skipped — re-sync will + // recover the row. + } - let wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); + // Recompute balances from the freshly-loaded UTXO set. Raw + // `account.utxos.insert` bypasses the normal `record_transaction` + // path that keeps the per-account `balance` field in sync, so + // the per-account confirmed/unconfirmed/immature/locked totals + // and the wallet-level rollup stay zero unless we tell the info + // to reread them. `update_balance` walks every funds account, + // recomputes from `utxos` against `metadata.last_processed_height`, + // and sums into `wallet_info.balance`. The lock-free + // `Arc` the UI reads is mirrored in + // `manager::load::load_from_persistor` (`WalletBalance::set` is + // `pub(crate)` to platform-wallet). + if routed > 0 { + wallet_info.update_balance(); + } 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/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index b4a8d3e5180..65b63d8b5da 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -151,7 +151,7 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( let entries: Vec = balances .into_iter() - .map(|(account_type, balance)| { + .map(|(account_type, balance, keys_used, keys_total)| { let tags = crate::core_wallet_types::account_type_to_tags(&account_type); crate::core_wallet_types::AccountBalanceEntryFFI { type_tag: tags.type_tag, @@ -165,6 +165,8 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( unconfirmed: balance.unconfirmed(), immature: balance.immature(), locked: balance.locked(), + keys_used, + keys_total, } }) .collect(); diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 1ff953aab3a..c22288e0bdc 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -215,6 +215,40 @@ pub struct IdentityRestoreEntryFFI { pub keys_count: usize, } +/// One unspent UTXO row to rehydrate into a funds-bearing account's +/// `ManagedCoreFundsAccount.utxos` map at startup. +/// +/// The leading account-tag block is the same `(type_tag, standard_tag, +/// index, registration_index, key_class, user_identity_id, +/// friend_identity_id)` shape `AccountSpecFFI` uses, so the loader can +/// reuse `account_type_from_spec` for routing. Keys-only and +/// PlatformPayment variants are skipped on the receive side — they +/// don't carry UTXOs. +/// +/// `script_pubkey` is a Swift-owned byte buffer; the address string is +/// reconstructed from `(script_pubkey, network)` on the Rust side, so +/// no C-string field is needed here. +#[repr(C)] +pub struct UtxoRestoreEntryFFI { + pub type_tag: AccountTypeTagFFI, + pub standard_tag: StandardAccountTypeTagFFI, + pub account_index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub prev_txid: [u8; 32], + pub vout: u32, + pub value_duffs: u64, + pub script_pubkey: *const u8, + pub script_pubkey_len: usize, + pub height: u32, + pub is_coinbase: bool, + pub is_confirmed: bool, + pub is_instantlocked: bool, + pub is_locked: bool, +} + /// Per-wallet entry returned by `on_load_wallet_list_fn`. /// /// `accounts` points to a contiguous array of length `accounts_count`. @@ -242,6 +276,20 @@ pub struct WalletRestoreEntryFFI { /// `null` / `0` when the wallet has no persisted identities. pub identities: *const IdentityRestoreEntryFFI, pub identities_count: usize, + /// Core-chain sync metadata stamped onto the rebuilt + /// `ManagedWalletInfo.metadata` at load time. Zero is treated as + /// "unknown" — the snapshot leaves the field at its default in + /// that case (which `from_wallet` already seeds from + /// `birth_height - 1`). `last_synced` is Unix seconds. + pub birth_height: u32, + pub synced_height: u32, + pub last_processed_height: u32, + pub last_synced: u64, + /// Persisted unspent UTXOs to repopulate funds-bearing accounts. + /// Swift-owned, freed by `LoadWalletListFreeFn` — including each + /// row's `script_pubkey` buffer. + pub utxos: *const UtxoRestoreEntryFFI, + pub utxos_count: usize, } // SAFETY: Pointers are Swift-owned and lifetime-scoped to the callback. @@ -255,6 +303,8 @@ unsafe impl Send for IdentityRestoreEntryFFI {} unsafe impl Sync for IdentityRestoreEntryFFI {} unsafe impl Send for WalletRestoreEntryFFI {} unsafe impl Sync for WalletRestoreEntryFFI {} +unsafe impl Send for UtxoRestoreEntryFFI {} +unsafe impl Sync for UtxoRestoreEntryFFI {} /// Paired free callback for the wallet-list load callback. Releases /// any memory Swift allocated for the entries array, the per-wallet diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 829f69e4ea1..dcefc28698c 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -193,8 +193,14 @@ async fn is_chain_locked( let Some(info) = guard.get_wallet_info(wallet_id) else { return false; }; + // Walk every account; if any holds an in-memory record for this + // txid, the chain-lock determination falls out of its + // `TransactionContext`. With `keep_txs_in_memory` off (the default) + // `get_transaction` returns `None` regardless of state — chain-lock + // delivery is event-driven in that mode, and this helper just + // reports "no record locally" by returning false. for account in info.core_wallet.accounts.all_accounts() { - if let Some(record) = account.transactions.get(txid) { + if let Some(record) = account.get_transaction(txid) { return matches!(record.context, TransactionContext::InChainLockedBlock(_)); } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 5b912a9ed95..1e8239c5e32 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -2,7 +2,12 @@ use std::sync::Arc; +use dashcore::{OutPoint, Txid}; +use dpp::prelude::Identifier; use key_wallet::account::AccountType; +use key_wallet::managed_account::address_pool::{AddressInfo, AddressPool, AddressPoolType}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::utxo::Utxo; use key_wallet::WalletCoreBalance; use crate::changeset::PlatformWalletPersistence; @@ -14,6 +19,168 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; +/// Snapshot of [`PlatformAddressSyncManager`] tunables and last-event +/// counters, returned from +/// [`PlatformWalletManager::platform_address_sync_config_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct PlatformAddressSyncConfigSnapshot { + pub interval_seconds: u64, + pub watch_list_size: usize, + pub last_event_wallet_count: u32, + pub last_event_unix_seconds: u64, +} + +/// Snapshot of [`IdentitySyncManager`] tunables / queue depth, returned +/// from [`PlatformWalletManager::identity_sync_config_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct IdentitySyncConfigSnapshot { + pub interval_seconds: u64, + pub queue_depth: usize, +} + +/// Snapshot of the core SPV state for a single wallet, returned from +/// [`PlatformWalletManager::core_wallet_state_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct CoreWalletStateSnapshot { + pub synced_height: u32, + pub last_processed_height: u32, + pub monitor_revision: u64, +} + +/// Snapshot of the identity-wallet scan state for a single wallet, +/// returned from +/// [`PlatformWalletManager::identity_wallet_state_blocking`]. +/// +/// `last_scanned_index` is sourced from +/// `IdentityManager::highest_registration_index`, which replaced the +/// old explicit `last_scanned_index` watermark — see the doc comment +/// on that accessor. +/// +/// `scan_pending` is reserved for future use; the gap-limit scan now +/// resumes implicitly from `highest_registration_index + 1` rather +/// than carrying a flag on the manager, so this value is always +/// `false` today. +#[derive(Debug, Clone, Copy)] +pub struct IdentityWalletStateSnapshot { + pub last_scanned_index: u32, + pub scan_pending: bool, +} + +/// Snapshot of the platform-address provider state for a single +/// wallet, returned from +/// [`PlatformWalletManager::platform_address_provider_state_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct PlatformAddressProviderStateSnapshot { + pub initialized: bool, + pub accounts_watched: usize, + pub found_count: usize, + pub known_balances_count: usize, + pub watermark_height: u32, +} + +// `WalletInfoMetadataSnapshot` and `wallet_info_metadata_blocking` +// were removed: the diagnostic explorer's "PlatformWalletInfo Metadata" +// section duplicated `CoreWalletStateSnapshot` (heights/revision) and +// surfaced fields with no active populator (total_transactions is +// event-driven; first_loaded_at isn't stamped on this path; name / +// description are wallet-row labels, not part of the in-memory diag +// surface). Re-add only if a future caller needs the name/description +// specifically. + +/// One row of the tracked-asset-lock list, returned from +/// [`PlatformWalletManager::tracked_asset_locks_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct TrackedAssetLockSnapshot { + pub outpoint: OutPoint, + /// 0 = `AssetLockBuilder` index funding type variant; project the + /// upstream `AssetLockFundingType` enum into a u8 lazily — see + /// [`asset_lock_funding_type_to_u8`]. + pub lock_type: u8, + /// 0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked. + pub status: u8, + pub registration_index: u32, + pub instant_lock_present: bool, + pub chain_lock_height: u32, +} + +/// Snapshot of the per-account metadata for a single account. +/// +/// `is_watch_only` and `custom_name` were dropped after upstream +/// removed both from `ManagedCoreFundsAccount` / `ManagedCoreKeysAccount`. +/// Watch-only is now a wallet-level property (read off `Wallet.wallet_type`) +/// and `AccountMetadata` no longer exists. Re-add fields here only if +/// the upstream variants gain them again. +#[derive(Debug, Clone, Copy)] +pub struct AccountMetadataSnapshot { + pub total_transactions: u64, + pub total_utxos: u64, + pub monitor_revision: u64, +} + +/// Snapshot of one address-pool slot for the per-account drill-down. +#[derive(Debug, Clone)] +pub struct AccountAddressPoolSnapshot { + /// 0=External, 1=Internal, 2=Absent, 3=AbsentHardened. + pub pool_type: u8, + pub gap_limit: u32, + /// `i64`-encoded so `-1` cleanly signals "no addresses used yet" + /// without needing a side-channel. Fits inside the FFI surface + /// without splitting the field. + pub last_used_index: i64, + pub addresses: Vec, +} + +/// Snapshot of a single derived address inside an +/// [`AccountAddressPoolSnapshot`]. +#[derive(Debug, Clone)] +pub struct AccountAddressInfoSnapshot { + /// 20-byte HASH160 of the derived public key (i.e. the P2PKH + /// payload). Sourced from the address's `script_pubkey`. + pub pubkey_hash: [u8; 20], + pub address_index: u32, + pub is_used: bool, + /// Encoded address as the user would see it (Base58check P2PKH for + /// every account variant the explorer surfaces today). Built from + /// `AddressInfo.address.to_string()`. + pub address: String, + /// Raw bytes of the public key that derived this address — empty + /// when `AddressInfo.public_key` is `None` (e.g. address-only + /// pools that don't carry the derived key). Variant info (ECDSA / + /// EdDSA / BLS) is not surfaced separately; the bytes are typed + /// implicitly by the owning account variant. + pub public_key_bytes: Vec, +} + +/// Snapshot of one UTXO row inside an account. +#[derive(Debug, Clone)] +pub struct AccountUtxoSnapshot { + pub outpoint: OutPoint, + pub value_duffs: u64, + pub script_pubkey: Vec, + pub height: u32, + pub is_locked: bool, +} + +/// Snapshot of one transaction row inside an account. +#[derive(Debug, Clone, Copy)] +pub struct AccountTransactionSnapshot { + pub txid: Txid, + pub height: u32, + pub timestamp: u64, + pub value_delta_duffs: i64, + pub fee_duffs: u64, + pub is_coinbase: bool, +} + +/// One row of the wallet-bound identity list (registration index + +/// identity id) returned from +/// [`PlatformWalletManager::identity_manager_wallet_identities_blocking`]. +#[derive(Debug, Clone, Copy)] +pub struct WalletIdentityRowSnapshot { + pub registration_index: u32, + pub identity_id: [u8; 32], +} + impl PlatformWalletManager

{ /// The SDK instance. pub fn sdk(&self) -> &dash_sdk::Sdk { @@ -67,18 +234,23 @@ impl PlatformWalletManager

{ wallets.keys().copied().collect() } - /// Read per-account balance snapshots for a wallet. + /// Read per-account balance + key-usage snapshots for a wallet. + /// + /// Returns one tuple per managed account: the wallet's `AccountType`, + /// the live `WalletCoreBalance` (zero on keys-only variants by + /// construction), and (`keys_used`, `keys_total`) totals across the + /// account's address pools. Funds variants and keys variants both + /// expose pools the same way, so the count is meaningful in both + /// directions — the explorer surfaces it as the headline number on + /// keys-only rows where balance has no semantic content. /// - /// 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. + /// 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)> { + ) -> Vec<(AccountType, WalletCoreBalance, u32, u32)> { let wm = self.wallet_manager.blocking_read(); let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); @@ -87,23 +259,537 @@ impl PlatformWalletManager

{ .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| { + // Balance lives on the funds-bearing variant only; + // keys-only accounts (identity, asset-lock, provider) + // never carry UTXOs. + let balance = account + .as_funds() + .map(|a| a.balance) + .unwrap_or_default(); + // Walk every pool on the account, sum + // `used` + total entries. Cheap — pools are bounded by + // the gap limit. + let (keys_used, keys_total) = account + .managed_account_type() + .address_pools() + .iter() + .fold((0u32, 0u32), |(used, total), pool| { + let pool_used = pool + .addresses + .values() + .filter(|info| info.used) + .count() as u32; + let pool_total = pool.addresses.len() as u32; + (used + pool_used, total + pool_total) + }); ( - account.managed_account_type.to_account_type(), - account.balance, + account.managed_account_type().to_account_type(), + balance, + keys_used, + keys_total, ) }) .collect() } + + // ----------------------------------------------------------------- + // Phase 2 — Manager-level diagnostic snapshots + // ----------------------------------------------------------------- + + /// Atomic snapshot of every wallet id currently registered on the + /// manager. Cheap (`Arc` read + `BTreeMap` key clone). + pub fn list_wallet_ids_blocking(&self) -> Vec { + let wallets = self.wallets.blocking_read(); + wallets.keys().copied().collect() + } + + /// Snapshot of [`PlatformAddressSyncManager`] tunables and last- + /// pass counters. The "watch list size" / "last event wallet + /// count" pair are the same value today (the sync manager doesn't + /// keep a separate watch list — every registered wallet is in the + /// pass), but the snapshot exposes both fields so the diagnostic + /// surface can grow if the two ever diverge. + pub fn platform_address_sync_config_blocking(&self) -> PlatformAddressSyncConfigSnapshot { + let wallets = self.wallets.blocking_read(); + let count = wallets.len(); + drop(wallets); + let interval = self.platform_address_sync_manager.interval(); + let last = self + .platform_address_sync_manager + .last_sync_unix_seconds() + .unwrap_or(0); + PlatformAddressSyncConfigSnapshot { + interval_seconds: interval.as_secs().max(1), + watch_list_size: count, + last_event_wallet_count: count as u32, + last_event_unix_seconds: last, + } + } + + /// Snapshot of [`IdentitySyncManager`] tunables and queue depth. + /// `queue_depth` reports the number of identities currently in the + /// per-identity registry (i.e. the number of identities the next + /// pass would touch). The manager doesn't expose a sync method to + /// read the registry without an `await`, so we use the + /// `interval_secs` getter and a coarse "is_running" probe. + pub fn identity_sync_config_blocking(&self) -> IdentitySyncConfigSnapshot { + let interval = self.identity_sync_manager.interval(); + // The registry behind `IdentitySyncManager.state` is async-only + // (`tokio::sync::RwLock`). Use `blocking_read` on the registry + // through a helper on the manager — since the registry itself + // isn't exposed, fall back to "0" until a sync getter is + // added. This is intentionally a TODO surface, not a guess. + let queue_depth = match self.identity_sync_manager.try_queue_depth() { + Some(n) => n, + None => 0, + }; + IdentitySyncConfigSnapshot { + interval_seconds: interval.as_secs().max(1), + queue_depth, + } + } + + // ----------------------------------------------------------------- + // Phase 3 — Per-wallet state + // ----------------------------------------------------------------- + + /// Snapshot of the core wallet's SPV bookkeeping for a single + /// wallet. `monitor_revision` is the max across every account on + /// the wallet — the max picks up the most recent address-set + /// mutation the bloom-filter rebuilder cares about. + pub fn core_wallet_state_blocking( + &self, + wallet_id: &WalletId, + ) -> Option { + let wm = self.wallet_manager.blocking_read(); + let info = wm.get_wallet_info(wallet_id)?; + let monitor_revision = info + .core_wallet + .accounts + .all_accounts() + .iter() + .map(|a| a.monitor_revision()) + .max() + .unwrap_or(0); + Some(CoreWalletStateSnapshot { + synced_height: info.core_wallet.metadata.synced_height, + last_processed_height: info.core_wallet.metadata.last_processed_height, + monitor_revision, + }) + } + + /// Snapshot of identity-wallet scan state for a single wallet. + /// See [`IdentityWalletStateSnapshot`] for the field doc and the + /// upstream renaming history (the legacy `last_scanned_index` + /// watermark was replaced with `highest_registration_index`). + pub fn identity_wallet_state_blocking( + &self, + wallet_id: &WalletId, + ) -> Option { + let wm = self.wallet_manager.blocking_read(); + let info = wm.get_wallet_info(wallet_id)?; + let last_scanned_index = info + .identity_manager + .highest_registration_index(wallet_id) + .unwrap_or(0); + Some(IdentityWalletStateSnapshot { + last_scanned_index, + // TODO(diagnostic): plumb a real `scan_pending` flag from + // the discovery scan once the gap-limit walker carries + // one. The watermark-only model can't express it. + scan_pending: false, + }) + } + + /// Snapshot of the unified [`PlatformPaymentAddressProvider`] + /// state for a single wallet. Returns + /// `initialized = false` (with zeroed counters) if the provider + /// hasn't been built yet. + /// + /// `accounts_watched` counts platform payment accounts on this + /// wallet that the provider tracks; `found_count` and + /// `known_balances_count` aggregate across those accounts. The + /// provider stores `found` / `addresses` per account, so both are + /// summed. + /// + /// Acquires the provider's `RwLock` via `blocking_read` — must + /// not be called from inside a tokio async task. + pub fn platform_address_provider_state_blocking( + &self, + wallet_id: &WalletId, + ) -> Option { + let wallets = self.wallets.blocking_read(); + let wallet = wallets.get(wallet_id)?.clone(); + drop(wallets); + let provider_lock = wallet.platform().provider_for_diagnostics(); + let guard = provider_lock.blocking_read(); + let Some(provider) = guard.as_ref() else { + return Some(PlatformAddressProviderStateSnapshot { + initialized: false, + accounts_watched: 0, + found_count: 0, + known_balances_count: 0, + watermark_height: 0, + }); + }; + let (accounts_watched, found_count, known_balances_count) = + provider.diagnostic_counts(wallet_id); + Some(PlatformAddressProviderStateSnapshot { + initialized: true, + accounts_watched, + found_count, + known_balances_count, + watermark_height: provider.diagnostic_sync_height_u32(), + }) + } + + // ----------------------------------------------------------------- + // Phase 4 — Wallet metadata + floating state + // ----------------------------------------------------------------- + + /// Snapshot of the wallet's tracked-asset-lock list. Reads the + /// `info.tracked_asset_locks` map once under the lock. + pub fn tracked_asset_locks_blocking( + &self, + wallet_id: &WalletId, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + info.tracked_asset_locks + .values() + .map(|lock| { + use crate::wallet::asset_lock::tracked::AssetLockStatus; + let status: u8 = match &lock.status { + AssetLockStatus::Built => 0, + AssetLockStatus::Broadcast => 1, + AssetLockStatus::InstantSendLocked => 2, + AssetLockStatus::ChainLocked => 3, + }; + let (instant_lock_present, chain_lock_height) = match &lock.proof { + Some(dpp::prelude::AssetLockProof::Instant(_)) => (true, 0u32), + Some(dpp::prelude::AssetLockProof::Chain(c)) => { + (false, c.core_chain_locked_height) + } + None => (false, 0u32), + }; + TrackedAssetLockSnapshot { + outpoint: lock.out_point, + lock_type: asset_lock_funding_type_to_u8(&lock.funding_type), + status, + registration_index: lock.identity_index, + instant_lock_present, + chain_lock_height, + } + }) + .collect() + } + + /// Snapshot of the wallet's InstantSend lock txid set. Returns + /// the txids in `HashSet` iteration order (non-deterministic + /// between runs, deterministic within a run while the set is + /// untouched). + pub fn instant_send_locks_blocking(&self, wallet_id: &WalletId) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + info.core_wallet + .instant_send_locks() + .iter() + .copied() + .collect() + } + + // ----------------------------------------------------------------- + // Phase 5 — Per-account drill-down + // ----------------------------------------------------------------- + + /// Snapshot of the per-account metadata for one account. + /// + /// `target` is matched against the canonical `AccountType` projected + /// from each `ManagedCoreAccount.managed_account_type` — same + /// equality the changeset / persistence path uses. + pub fn account_metadata_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Option { + let wm = self.wallet_manager.blocking_read(); + let info = wm.get_wallet_info(wallet_id)?; + let accounts = info.core_wallet.accounts.all_accounts(); + let account = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target)?; + // Funds-only fields (`utxos`) live on the funds variant; the + // ref-enum delegates the rest. `transactions_iter()` returns an + // empty iterator when `keep_txs_in_memory` is off (the default + // — tx history is event-driven), so `total_transactions` reads + // 0 in production builds. Both behaviors are intentional. + let funds = account.as_funds(); + Some(AccountMetadataSnapshot { + // `transactions_iter()` returns empty when + // `keep_txs_in_memory` is off (the default — tx history is + // event-driven), so `total_transactions` reads 0 in + // production builds. + total_transactions: account.transactions_iter().count() as u64, + total_utxos: funds.map(|a| a.utxos.len() as u64).unwrap_or(0), + monitor_revision: account.monitor_revision(), + }) + } + + /// Snapshot of the address pools for one account. Each pool + /// carries every derived address; pools are returned in the + /// order [`crate`]: `address_pools()` exposes them, which is + /// `[external, internal]` for `Standard` and a single pool for + /// every other variant. + pub fn account_address_pools_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + account + .managed_account_type() + .address_pools() + .iter() + .map(|pool| pool_snapshot(pool)) + .collect() + } + + /// Snapshot of every UTXO row on one account. + pub fn account_utxos_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + // UTXOs only exist on the funds variant. Keys-only accounts + // (identity / asset-lock / provider) never carry UTXOs by + // construction, so an empty list is the correct snapshot. + let Some(funds) = account.as_funds() else { + return Vec::new(); + }; + funds + .utxos + .values() + .map(|utxo: &Utxo| AccountUtxoSnapshot { + outpoint: utxo.outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + height: utxo.height, + is_locked: utxo.is_locked, + }) + .collect() + } + + // ----------------------------------------------------------------- + // Phase 6 — Per-account transactions + // ----------------------------------------------------------------- + + /// Paginated snapshot of an account's transaction list. + /// + /// `page_offset` skips the first `page_offset` records; + /// `page_limit == 0` means "no limit", any other value caps the + /// returned slice at `page_limit` rows. Records iterate in + /// `BTreeMap` order — deterministic but not + /// chronological. + pub fn account_transactions_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + page_offset: usize, + page_limit: usize, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + // `transactions_iter` is the variant-agnostic walk and returns + // an empty iterator when `keep_txs_in_memory` is disabled — the + // default. Tx history is delivered through the event channel, + // not stored in-memory, so a paged readout here is effectively + // a debug surface for builds that flip the feature on. We + // collapse `(Txid, &TransactionRecord)` to just records, since + // the snapshot type carries the txid as a field of its own. + let iter = account + .transactions_iter() + .map(|(_, record)| record) + .skip(page_offset); + let take = if page_limit == 0 { + usize::MAX + } else { + page_limit + }; + iter.take(take).map(tx_record_snapshot).collect() + } + + // ----------------------------------------------------------------- + // Phase 7 — Identity manager structure + // ----------------------------------------------------------------- + + /// Snapshot of the wallet's `out_of_wallet_identities` keys + /// (i.e. observed but un-owned identities the manager tracks). + /// Reading the per-identity drill-down still goes through the + /// existing `get_managed_identity` FFI. + pub fn identity_manager_out_of_wallet_ids_blocking( + &self, + wallet_id: &WalletId, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + info.identity_manager + .out_of_wallet_identities + .keys() + .copied() + .collect() + } + + /// Ordered list of `(registration_index, identity_id)` rows for + /// a single wallet. `registration_index` is the inner-bucket key, + /// so the rows come out in BIP-9 index order. + pub fn identity_manager_wallet_identities_blocking( + &self, + wallet_id: &WalletId, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let Some(inner) = info.identity_manager.wallet_identities.get(wallet_id) else { + return Vec::new(); + }; + inner + .iter() + .map(|(reg_idx, managed)| { + use dpp::identity::accessors::IdentityGettersV0; + WalletIdentityRowSnapshot { + registration_index: *reg_idx as u32, + identity_id: managed.identity.id().to_buffer(), + } + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Helper conversions used by the snapshot accessors. +// --------------------------------------------------------------------------- + +/// Project upstream `AssetLockFundingType` into the diagnostic FFI's +/// stable `lock_type: u8`. Variant order pinned to upstream +/// declaration order. +fn asset_lock_funding_type_to_u8( + ty: &key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType, +) -> u8 { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + match ty { + AssetLockFundingType::IdentityRegistration => 0, + AssetLockFundingType::IdentityTopUp => 1, + AssetLockFundingType::IdentityTopUpNotBound => 2, + AssetLockFundingType::IdentityInvitation => 3, + AssetLockFundingType::AssetLockAddressTopUp => 4, + AssetLockFundingType::AssetLockShieldedAddressTopUp => 5, + } +} + +fn pool_snapshot(pool: &AddressPool) -> AccountAddressPoolSnapshot { + let pool_type: u8 = match pool.pool_type { + AddressPoolType::External => 0, + AddressPoolType::Internal => 1, + AddressPoolType::Absent => 2, + AddressPoolType::AbsentHardened => 3, + }; + let last_used_index: i64 = pool.highest_used.map(|i| i as i64).unwrap_or(-1); + let addresses = pool + .addresses + .values() + .map(|info| addr_info_snapshot(info)) + .collect(); + AccountAddressPoolSnapshot { + pool_type, + gap_limit: pool.gap_limit, + last_used_index, + addresses, + } +} + +fn addr_info_snapshot(info: &AddressInfo) -> AccountAddressInfoSnapshot { + // The address pool stores `script_pubkey` directly. P2PKH is the + // dominant shape here, so pull the 20-byte HASH160 out via + // `p2pkh_public_key_hash_bytes`. Non-P2PKH script types simply + // surface zeroed bytes — the diagnostic surface stays a flat + // `[u8; 20]` either way. + let mut pubkey_hash = [0u8; 20]; + if let Some(bytes) = info.script_pubkey.p2pkh_public_key_hash_bytes() { + if bytes.len() == 20 { + pubkey_hash.copy_from_slice(bytes); + } + } + // Pull the encoded address + raw public-key bytes for the explorer + // to display. `info.public_key` is `None` on pools that store only + // the script_pubkey without retaining the derivation source, so an + // empty `Vec` is the correct shape there. + let address = info.address.to_string(); + let public_key_bytes = match &info.public_key { + Some(key_wallet::managed_account::address_pool::PublicKeyType::ECDSA(b)) + | Some(key_wallet::managed_account::address_pool::PublicKeyType::EdDSA(b)) + | Some(key_wallet::managed_account::address_pool::PublicKeyType::BLS(b)) => b.clone(), + None => Vec::new(), + }; + AccountAddressInfoSnapshot { + pubkey_hash, + address_index: info.index, + is_used: info.used, + address, + public_key_bytes, + } +} + +fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { + use key_wallet::transaction_checking::TransactionContext; + let (height, timestamp) = match &rec.context { + TransactionContext::Mempool | TransactionContext::InstantSend(_) => (0u32, 0u64), + TransactionContext::InBlock(bi) => (bi.height(), bi.timestamp() as u64), + TransactionContext::InChainLockedBlock(bi) => (bi.height(), bi.timestamp() as u64), + }; + AccountTransactionSnapshot { + txid: rec.txid, + height, + timestamp, + value_delta_duffs: rec.net_amount, + fee_duffs: rec.fee.unwrap_or(0), + is_coinbase: rec.transaction.is_coin_base(), + } } diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index 29d3b8f92e2..b998ea73e01 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -363,6 +363,15 @@ where state.clone() } + /// Best-effort registry depth for the diagnostic snapshot path. + /// Returns the number of identities currently registered, or + /// `None` if the registry can't be acquired without parking the + /// thread (i.e. another writer is in flight). The diagnostic + /// surface treats `None` as "0" so the caller never blocks. + pub fn try_queue_depth(&self) -> Option { + self.state.try_read().ok().map(|s| s.len()) + } + /// Start the background sync loop. Idempotent — calling while /// already running is a no-op. /// diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 3ef8f610105..c1be66159e6 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -59,6 +59,22 @@ impl PlatformWalletManager

{ } let balance = Arc::new(WalletBalance::new()); + // Mirror the inner `ManagedWalletInfo.balance` (already + // recomputed from the freshly-loaded UTXO set on the FFI + // side via `update_balance`) into the lock-free `Arc` the + // UI reads. Without this, `wallet.balance()` reports zero + // for restored wallets even though the per-account totals + // and the inner `core_wallet.balance` are correct. + // `WalletBalance::set` is `pub(crate)`, which is why this + // step has to live inside `platform_wallet` rather than + // the FFI loader. + let core_balance = &wallet_info.balance; + balance.set( + core_balance.confirmed(), + core_balance.unconfirmed(), + core_balance.immature(), + core_balance.locked(), + ); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3a929943889..7870a18382a 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1,6 +1,6 @@ //! Multi-wallet manager with SPV coordination. -mod accessors; +pub mod accessors; pub mod identity_sync; mod load; pub mod platform_address_sync; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index cf751b04809..826d5e8f3b4 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -129,9 +129,13 @@ impl PlatformWalletManager

{ .all_managed_accounts() .iter() .map(|managed| { - let account_type = managed.managed_account_type.to_account_type(); + // `all_managed_accounts()` returns `ManagedAccountRef`; + // the upstream split made `managed_account_type` a + // delegating method (it was a field on the pre-split + // unified `ManagedCoreAccount`). + let account_type = managed.managed_account_type().to_account_type(); let pools = managed - .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 c376a8d8253..0044d992854 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -138,8 +138,12 @@ impl IdentityWallet { let info = wm .get_wallet_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let managed = key_wallet::managed_account::ManagedCoreAccount::from_account(&account); - info.core_wallet.accounts.insert(managed).map_err(|e| { + // DashPay accounts are funds-bearing; use the typed + // `insert_funds` API exposed by the post-split collection + // rather than wrapping in `OwnedManagedCoreAccount`. + let managed = + key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); + info.core_wallet.accounts.insert_funds(managed).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to register contact account: {e}" )) @@ -468,7 +472,10 @@ impl IdentityWallet { is_watch_only: true, }; - let managed = key_wallet::managed_account::ManagedCoreAccount::from_account(&account); + // DashpayExternalAccount is funds-bearing; insert via the + // typed `insert_funds` API after the upstream split. + let managed = + key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm @@ -486,8 +493,8 @@ impl IdentityWallet { )) })?; - // (b) Insert ManagedCoreAccount for address-pool tracking. - info.core_wallet.accounts.insert(managed).map_err(|e| { + // (b) Insert ManagedCoreFundsAccount for address-pool tracking. + info.core_wallet.accounts.insert_funds(managed).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to register external contact account: {}", e diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index 807b549f8a1..52311e24356 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -432,6 +432,41 @@ impl PlatformPaymentAddressProvider { self.sync_timestamp = timestamp; self.last_known_recent_block = last_known_recent_block; } + + /// Diagnostic snapshot counts used by the read-only memory + /// explorer surface on + /// [`crate::manager::PlatformWalletManager::platform_address_provider_state_blocking`]. + /// Returns `(accounts_watched, found_count, known_balances_count)` + /// for `wallet_id`. Reading both `found.len()` and `addresses.len()` + /// from the same per-account state captures the two concepts the + /// explorer wants to surface separately. + pub fn diagnostic_counts(&self, wallet_id: &WalletId) -> (usize, usize, usize) { + let Some(state) = self.per_wallet.get(wallet_id) else { + return (0, 0, 0); + }; + let accounts_watched = state.len(); + let mut found_count = 0; + let mut known_balances_count = 0; + for account_state in state.values() { + // `found` holds proven-present addresses with balances — + // this is exactly the "currently has a balance" set the + // SDK seeds the next pass with. + found_count += account_state.found.len(); + // `addresses` is the bijection of every derivation index + // we've ever tracked for this account, so its size is the + // "known balances slot count" the explorer reports. + known_balances_count += account_state.addresses.len(); + } + (accounts_watched, found_count, known_balances_count) + } + + /// Diagnostic getter — the unified-pass watermark height as a + /// `u32` (the SDK exposes it as `u64` internally; the diagnostic + /// surface is `u32` to match the rest of the explorer's height + /// fields). + pub fn diagnostic_sync_height_u32(&self) -> u32 { + self.sync_height as u32 + } } #[async_trait] diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 7c618aaf0d5..0c08fc8a425 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -163,6 +163,16 @@ impl PlatformAddressWallet { ) .await; } + + /// Internal accessor for the diagnostic snapshot path on + /// [`crate::manager::PlatformWalletManager`]. The provider lock is + /// otherwise crate-private — the manager-level snapshot needs to + /// `blocking_read` it, which requires re-exposing the `Arc`. + pub(crate) fn provider_for_diagnostics( + &self, + ) -> Arc>> { + Arc::clone(&self.provider) + } } impl PlatformAddressWallet { 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 0e3917bff6a..b538f298237 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -80,13 +80,11 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.birth_height() } - fn first_loaded_at(&self) -> u64 { - self.core_wallet.first_loaded_at() - } - - fn set_first_loaded_at(&mut self, timestamp: u64) { - self.core_wallet.set_first_loaded_at(timestamp); - } + // `first_loaded_at` / `set_first_loaded_at` were dropped from + // `WalletInfoInterface` upstream and have no backing methods on + // `ManagedWalletInfo` anymore. The field still exists on + // `WalletMetadata` but is read/written directly there; the trait + // surface no longer requires delegating accessors here. fn update_last_synced(&mut self, timestamp: u64) { self.core_wallet.update_last_synced(timestamp); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift index 728616096bb..8b3a3746adb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/ManagedAccount.swift @@ -30,10 +30,11 @@ public class ManagedAccount { return AccountType(ffiType: ffiType) } - /// Check if this is a watch-only account - public var isWatchOnly: Bool { - return managed_core_account_get_is_watch_only(handle) - } + // `isWatchOnly` was removed in lockstep with upstream dropping the + // per-core-account flag (it's now a wallet-level property on + // `WalletType::WatchOnly`). The corresponding C getter + // `managed_core_account_get_is_watch_only` is gone too. Query + // watch-only from the parent wallet handle if needed. /// Get the account index public var index: UInt32 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 48d81eb39c0..df6503c09d8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -3,9 +3,14 @@ import SwiftData /// SwiftData model for persisting core wallet metadata. /// -/// Represents a single HD wallet with its sync state and balance. +/// Represents a single HD wallet with its sync state. /// Owns accounts via cascade delete — removing a wallet removes all /// its accounts, transactions, and UTXOs. +/// +/// The wallet-level cached balance fields were removed — the canonical +/// "live" Core balance is summed on demand from +/// `PlatformWalletManager.accountBalances(for:)` (Rust in-memory FFI). +/// Per-account totals continue to live on `PersistentAccount`. @Model public final class PersistentWallet { /// 32-byte wallet ID (SHA256 of root public key). @@ -37,14 +42,6 @@ public final class PersistentWallet { public var syncedHeight: UInt32 /// Timestamp of last sync (Unix seconds). public var lastSynced: UInt64 - /// Confirmed balance in duffs. - public var balanceConfirmed: UInt64 - /// Unconfirmed balance in duffs. - public var balanceUnconfirmed: UInt64 - /// Immature balance in duffs. - public var balanceImmature: UInt64 - /// Locked balance in duffs. - public var balanceLocked: UInt64 /// User imported this wallet from an existing mnemonic (as /// opposed to generating a fresh one). Cosmetic flag that /// drives the "📥 Imported" badge; defaulted to `false` for @@ -84,10 +81,6 @@ public final class PersistentWallet { self.birthHeight = birthHeight self.syncedHeight = syncedHeight self.lastSynced = 0 - self.balanceConfirmed = 0 - self.balanceUnconfirmed = 0 - self.balanceImmature = 0 - self.balanceLocked = 0 self.isImported = isImported self.createdAt = Date() self.lastUpdated = Date() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c5c791907fb..6d134fe2fb3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -352,6 +352,13 @@ public class PlatformWalletManager: ObservableObject { // MARK: - Per-account balances /// Per-account balance snapshot read from Rust's in-memory state. + /// + /// `keysUsed` / `keysTotal` are the number of derived addresses + /// across every pool on the account, with `keysUsed` further + /// filtered by `AddressInfo.used`. The fields are populated for + /// both funds and keys variants — the explorer surfaces them as + /// the headline number on keys-only rows where balance is zero by + /// construction. public struct AccountBalance { public let typeTag: UInt8 public let standardTag: UInt8 @@ -364,6 +371,8 @@ public class PlatformWalletManager: ObservableObject { public let unconfirmed: UInt64 public let immature: UInt64 public let locked: UInt64 + public let keysUsed: UInt32 + public let keysTotal: UInt32 } /// Query per-account balances directly from the Rust-side @@ -422,7 +431,9 @@ public class PlatformWalletManager: ObservableObject { confirmed: entry.confirmed, unconfirmed: entry.unconfirmed, immature: entry.immature, - locked: entry.locked + locked: entry.locked, + keysUsed: entry.keys_used, + keysTotal: entry.keys_total ) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift new file mode 100644 index 00000000000..f48556f54f0 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift @@ -0,0 +1,511 @@ +import Foundation +import DashSDKFFI + +// Read-only diagnostic surface mirroring the `*_blocking` snapshot +// accessors on the Rust-side `PlatformWalletManager`. Every type and +// method here is a flat 1:1 bridge — marshal in, call FFI, marshal +// out, free, return. The decision logic lives upstream in +// `platform_wallet::manager::accessors`. + +extension PlatformWalletManager { + + // MARK: - Phase 2 — Manager-level snapshots + + /// Atomic snapshot of every wallet id currently registered on the + /// Rust manager. Avoids the Swift-side `wallets` cache so callers + /// debugging cache drift can compare the two. + public func listWalletIdsAtomic() -> [Data] { + guard isConfigured, handle != NULL_HANDLE else { return [] } + + var outBytes: UnsafePointer? = nil + var outCount: UInt = 0 + let res = platform_wallet_manager_list_wallet_ids(handle, &outBytes, &outCount) + guard PlatformWalletResult(res).isSuccess, let ptr = outBytes, outCount > 0 else { + return [] + } + defer { platform_wallet_manager_free_wallet_ids(UnsafeMutablePointer(mutating: ptr), outCount) } + return walletIdsFromFlatBuffer(ptr: ptr, count: Int(outCount)) + } + + public struct PlatformAddressSyncConfigSnapshot { + public let intervalSeconds: UInt64 + public let watchListSize: Int + public let lastEventWalletCount: UInt32 + public let lastEventUnixSeconds: UInt64 + } + + public func platformAddressSyncConfigSnapshot() -> PlatformAddressSyncConfigSnapshot? { + guard isConfigured, handle != NULL_HANDLE else { return nil } + var out = PlatformAddressSyncConfigFFI( + interval_seconds: 0, + watch_list_size: 0, + last_event_wallet_count: 0, + last_event_unix_seconds: 0 + ) + let res = platform_wallet_manager_platform_address_sync_config(handle, &out) + guard PlatformWalletResult(res).isSuccess else { return nil } + return PlatformAddressSyncConfigSnapshot( + intervalSeconds: out.interval_seconds, + watchListSize: Int(out.watch_list_size), + lastEventWalletCount: out.last_event_wallet_count, + lastEventUnixSeconds: out.last_event_unix_seconds + ) + } + + public struct IdentitySyncConfigSnapshot { + public let intervalSeconds: UInt64 + public let queueDepth: Int + } + + public func identitySyncConfigSnapshot() -> IdentitySyncConfigSnapshot? { + guard isConfigured, handle != NULL_HANDLE else { return nil } + var out = IdentitySyncConfigFFI(interval_seconds: 0, queue_depth: 0) + let res = platform_wallet_manager_identity_sync_config(handle, &out) + guard PlatformWalletResult(res).isSuccess else { return nil } + return IdentitySyncConfigSnapshot( + intervalSeconds: out.interval_seconds, + queueDepth: Int(out.queue_depth) + ) + } + + // MARK: - Phase 3 — Per-wallet state + + public struct CoreWalletStateSnapshot { + public let syncedHeight: UInt32 + public let lastProcessedHeight: UInt32 + public let monitorRevision: UInt64 + } + + public func coreWalletState(for walletId: Data) -> CoreWalletStateSnapshot? { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return nil } + var out = CoreWalletStateFFI( + synced_height: 0, + last_processed_height: 0, + monitor_revision: 0 + ) + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_core_wallet_state(handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), &out) + } + guard PlatformWalletResult(res).isSuccess else { return nil } + return CoreWalletStateSnapshot( + syncedHeight: out.synced_height, + lastProcessedHeight: out.last_processed_height, + monitorRevision: out.monitor_revision + ) + } + + public struct IdentityWalletStateSnapshot { + public let lastScannedIndex: UInt32 + public let scanPending: Bool + } + + public func identityWalletState(for walletId: Data) -> IdentityWalletStateSnapshot? { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return nil } + var out = IdentityWalletStateFFI(last_scanned_index: 0, scan_pending: false) + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_identity_wallet_state(handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), &out) + } + guard PlatformWalletResult(res).isSuccess else { return nil } + return IdentityWalletStateSnapshot( + lastScannedIndex: out.last_scanned_index, + scanPending: out.scan_pending + ) + } + + public struct PlatformAddressProviderStateSnapshot { + public let initialized: Bool + public let accountsWatched: Int + public let foundCount: Int + public let knownBalancesCount: Int + public let watermarkHeight: UInt32 + } + + public func platformAddressProviderState(for walletId: Data) -> PlatformAddressProviderStateSnapshot? { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return nil } + var out = PlatformAddressProviderStateFFI( + initialized: false, + accounts_watched: 0, + found_count: 0, + known_balances_count: 0, + watermark_height: 0 + ) + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_platform_address_provider_state(handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), &out) + } + guard PlatformWalletResult(res).isSuccess else { return nil } + return PlatformAddressProviderStateSnapshot( + initialized: out.initialized, + accountsWatched: Int(out.accounts_watched), + foundCount: Int(out.found_count), + knownBalancesCount: Int(out.known_balances_count), + watermarkHeight: out.watermark_height + ) + } + + // MARK: - Phase 4 — Floating state + // + // The `WalletInfoMetadataSnapshot` accessor (name / description / + // birth+synced+last-processed heights / total transactions / first + // loaded at) was removed: every meaningful field either duplicates + // `CoreWalletStateSnapshot` or has nothing populating it on this + // path. The C ABI (`platform_wallet_info_metadata*`) and the FFI + // struct were dropped in lockstep — re-add the surface only if a + // future caller needs name/description specifically. + + public struct TrackedAssetLockSnapshot { + public let outpointTxid: Data + public let outpointVout: UInt32 + public let lockType: UInt8 + public let status: UInt8 + public let registrationIndex: UInt32 + public let instantLockPresent: Bool + public let chainLockHeight: UInt32 + } + + public func trackedAssetLocks(for walletId: Data) -> [TrackedAssetLockSnapshot] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var outEntries: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_tracked_asset_locks_list( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outEntries, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outEntries, outCount > 0 else { return [] } + defer { platform_wallet_tracked_asset_locks_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.. [Data] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var outBytes: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_instant_send_locks( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outBytes, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outBytes, outCount > 0 else { return [] } + defer { platform_wallet_instant_send_locks_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return walletIdsFromFlatBuffer(ptr: ptr, count: Int(outCount)) + } + + // MARK: - Phase 5 — Per-account drill-down + + /// Per-account metadata snapshot. + /// + /// `isWatchOnly` and `customName` were dropped after upstream + /// removed both fields from `ManagedCoreFundsAccount` / + /// `ManagedCoreKeysAccount`. Watch-only is now a wallet-level + /// property; account-level custom names no longer exist. + public struct AccountMetadataSnapshot { + public let totalTransactions: UInt64 + public let totalUtxos: UInt64 + public let monitorRevision: UInt64 + } + + public func accountMetadata( + for walletId: Data, + balance: AccountBalance + ) -> AccountMetadataSnapshot? { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return nil } + var spec = makeAccountSpec(from: balance) + var out = AccountMetadataFFI( + total_transactions: 0, + total_utxos: 0, + monitor_revision: 0 + ) + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_account_metadata( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + &out + ) + } + guard PlatformWalletResult(res).isSuccess else { return nil } + // Free fn is a no-op now (no heap fields), but call it so the + // surface stays consistent if upstream re-introduces owned data. + defer { platform_wallet_account_metadata_free(&out) } + return AccountMetadataSnapshot( + totalTransactions: out.total_transactions, + totalUtxos: out.total_utxos, + monitorRevision: out.monitor_revision + ) + } + + public struct AccountAddressInfo { + public let pubkeyHash: Data + public let addressIndex: UInt32 + public let isUsed: Bool + public let lastUsedHeight: UInt32 + /// Encoded address string (Base58check P2PKH for every account + /// variant the explorer surfaces today). + public let address: String + /// Raw bytes of the derived public key. Empty when the pool + /// entry didn't retain the derivation source — the FFI returns + /// `null` + `len == 0` in that case and we surface it as an + /// empty `Data`. + public let publicKeyBytes: Data + } + + public struct AccountAddressPool { + public let poolType: UInt8 + public let gapLimit: UInt32 + public let lastUsedIndex: Int64 + public let addresses: [AccountAddressInfo] + } + + public func accountAddressPools( + for walletId: Data, + balance: AccountBalance + ) -> [AccountAddressPool] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var spec = makeAccountSpec(from: balance) + var outPools: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_account_address_pools( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + &outPools, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outPools, outCount > 0 else { return [] } + defer { platform_wallet_account_address_pools_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.. 0 { + addresses.reserveCapacity(Int(entry.addresses_count)) + for j in 0.. 0 + else { return Data() } + return Data( + bytes: pkPtr, + count: Int(a.public_key_bytes_len) + ) + }() + addresses.append(AccountAddressInfo( + pubkeyHash: hash, + addressIndex: a.address_index, + isUsed: a.is_used, + lastUsedHeight: a.last_used_height, + address: address, + publicKeyBytes: publicKeyBytes + )) + } + } + return AccountAddressPool( + poolType: entry.pool_type, + gapLimit: entry.gap_limit, + lastUsedIndex: entry.last_used_index, + addresses: addresses + ) + } + } + + public struct AccountUtxo { + public let outpointTxid: Data + public let outpointVout: UInt32 + public let valueDuffs: UInt64 + public let scriptPubkey: Data + public let height: UInt32 + public let isLocked: Bool + } + + public func accountUtxos( + for walletId: Data, + balance: AccountBalance + ) -> [AccountUtxo] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var spec = makeAccountSpec(from: balance) + var outUtxos: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_account_utxos( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + &outUtxos, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outUtxos, outCount > 0 else { return [] } + defer { platform_wallet_account_utxos_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.. 0 { + scriptData = Data(bytes: sptr, count: Int(entry.script_pubkey_len)) + } else { + scriptData = Data() + } + return AccountUtxo( + outpointTxid: txid, + outpointVout: entry.outpoint_vout, + valueDuffs: entry.value_duffs, + scriptPubkey: scriptData, + height: entry.height, + isLocked: entry.is_locked + ) + } + } + + // MARK: - Phase 6 — Per-account transactions + + public struct AccountTransaction { + public let txid: Data + public let height: UInt32 + public let timestamp: UInt64 + public let valueDeltaDuffs: Int64 + public let feeDuffs: UInt64 + public let isCoinbase: Bool + } + + public func accountTransactions( + for walletId: Data, + balance: AccountBalance, + pageOffset: Int = 0, + pageLimit: Int = 0 + ) -> [AccountTransaction] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var spec = makeAccountSpec(from: balance) + var outTxs: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_account_transactions( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + UInt(pageOffset), + UInt(pageLimit), + &outTxs, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outTxs, outCount > 0 else { return [] } + defer { platform_wallet_account_transactions_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.. [Data] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var outBytes: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_identity_manager_out_of_wallet_ids( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outBytes, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outBytes, outCount > 0 else { return [] } + defer { platform_wallet_identity_manager_out_of_wallet_ids_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return walletIdsFromFlatBuffer(ptr: ptr, count: Int(outCount)) + } + + public struct WalletIdentityRow { + public let registrationIndex: UInt32 + public let identityId: Data + } + + public func identityManagerWalletIdentities(for walletId: Data) -> [WalletIdentityRow] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + var outRows: UnsafePointer? = nil + var outCount: UInt = 0 + let res = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_identity_manager_wallet_identities( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outRows, + &outCount + ) + } + guard PlatformWalletResult(res).isSuccess, let ptr = outRows, outCount > 0 else { return [] } + defer { platform_wallet_identity_manager_wallet_identities_free(UnsafeMutablePointer(mutating: ptr), outCount) } + return (0.., count: Int) -> [Data] { + var result: [Data] = [] + result.reserveCapacity(count) + for i in 0.. AccountSpecFFI { + var spec = AccountSpecFFI() + spec.type_tag = balance.typeTag + spec.standard_tag = balance.standardTag + spec.index = balance.index + spec.registration_index = balance.registrationIndex + spec.key_class = balance.keyClass + withUnsafeMutableBytes(of: &spec.user_identity_id) { raw in + let count = min(32, balance.userIdentityId.count) + balance.userIdentityId.copyBytes(to: raw.bindMemory(to: UInt8.self), count: count) + } + withUnsafeMutableBytes(of: &spec.friend_identity_id) { raw in + let count = min(32, balance.friendIdentityId.count) + balance.friendIdentityId.copyBytes(to: raw.bindMemory(to: UInt8.self), count: count) + } + spec.account_xpub_bytes = nil + spec.account_xpub_bytes_len = 0 + return spec +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 81da84f9d14..2f9a46d3a51 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -233,13 +233,13 @@ public class PlatformWalletPersistenceHandler { wallet.lastUpdated = Date() } - // Balance delta — apply signed changes to cached totals. + // Balance delta — Rust still emits per-round deltas, but the + // PersistentWallet `balance*` fields they used to update were + // removed (canonical source is now the in-memory account + // totals via `walletManager.accountBalances(for:)`). Bump the + // updated timestamp so the row reflects the persistence round + // and discard the payload itself. if cs.has_balance { - let b = cs.balance - wallet.balanceConfirmed = addDelta(wallet.balanceConfirmed, b.confirmed_delta) - wallet.balanceUnconfirmed = addDelta(wallet.balanceUnconfirmed, b.unconfirmed_delta) - wallet.balanceImmature = addDelta(wallet.balanceImmature, b.immature_delta) - wallet.balanceLocked = addDelta(wallet.balanceLocked, b.locked_delta) wallet.lastUpdated = Date() } @@ -2068,6 +2068,31 @@ public class PlatformWalletPersistenceHandler { entry.platform_last_known_recent_block = syncState?.lastKnownRecentBlock ?? 0 entry.identities = identitiesBuffer.map { UnsafePointer($0) } entry.identities_count = UInt(sortedIdentities.count) + // Core-chain sync metadata. `PersistentWallet` doesn't + // carry a separate `lastProcessedHeight` column today — + // re-use `syncedHeight` for both. The Rust loader treats + // zero as "unknown" and falls back to the + // `birth_height - 1` seed from `ManagedWalletInfo::from_wallet`, + // so freshly-recovered wallets without a sync watermark + // round-trip cleanly. + entry.birth_height = w.birthHeight + entry.synced_height = w.syncedHeight + entry.last_processed_height = w.syncedHeight + entry.last_synced = w.lastSynced + + // Persisted unspent UTXOs for this wallet. The SPV inbound + // path writes `PersistentTxo` rows and flips `isSpent` + // (rather than deleting) on spend, so the unspent set is + // exactly `isSpent == false`. Rust routes each row into + // the matching funds-bearing account by tag; rows whose + // account isn't a funds variant get silently skipped on + // the receiving side. + let (utxoBuf, utxoCount) = buildUtxoRestoreBuffer( + walletId: w.walletId, + allocation: allocation + ) + entry.utxos = utxoBuf.map { UnsafePointer($0) } + entry.utxos_count = UInt(utxoCount) // Primary-identity selection + gap-limit scan watermark // were dropped from the FFI shape — both moved off the // Rust manager (UI owns selection now, scan resume is @@ -2101,6 +2126,94 @@ public class PlatformWalletPersistenceHandler { /// on `PersistentIdentity` and is read directly by the UI; it /// no longer roundtrips through Rust now that `ManagedIdentity` /// dropped its `label` field. + /// Build a contiguous `[UtxoRestoreEntryFFI]` buffer for one + /// wallet's unspent UTXOs. Walks `PersistentTxo` rows scoped to + /// `walletId` and `isSpent == false`, copies the account-tag + /// fields off the parent `PersistentAccount`, and emits one row + /// per UTXO. Returns `(nil, 0)` for empty input — Rust treats + /// `null` + `count == 0` as "no UTXOs to restore". + /// + /// Per-row script_pubkey buffers and the outer array are tracked + /// on `allocation` so `loadWalletListFree` can release them. + /// Rows whose `outpoint` payload isn't 32 bytes are skipped — the + /// model stores it as `Data` (`outpoint: Data`) and bad data + /// shouldn't crash the FFI handoff. + private func buildUtxoRestoreBuffer( + walletId: Data, + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.isSpent == false } + ) + guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { + return (nil, 0) + } + // Rows missing a parent `account` can't be routed Rust-side. + // Drop them rather than emit an unmappable row. + let routable = rows.filter { $0.account != nil } + if routable.isEmpty { + return (nil, 0) + } + let buf = UnsafeMutablePointer.allocate(capacity: routable.count) + var written = 0 + for record in routable { + guard let account = record.account else { continue } + // `outpoint` on `PersistentTxo` is 36 bytes (32-byte txid + // followed by LE u32 vout) — composed via + // `makeOutpoint(txid:vout:)`. Use the dedicated `txid` + // accessor, which prefers `transaction.txid` and falls + // back to `outpoint.prefix(32)` so storage-explorer rows + // and the FFI handoff agree on the same 32-byte identity. + let txid = record.txid + guard txid.count == 32 else { continue } + + // Allocate + copy the script_pubkey bytes. Empty scripts + // pass through with a null pointer + zero len. + let scriptBytes = record.scriptPubKey + let scriptPtr: UnsafePointer? + let scriptLen = scriptBytes.count + if scriptLen > 0 { + let buffer = UnsafeMutablePointer.allocate(capacity: scriptLen) + scriptBytes.copyBytes(to: buffer, count: scriptLen) + allocation.scalarBuffers.append((buffer, scriptLen)) + scriptPtr = UnsafePointer(buffer) + } else { + scriptPtr = nil + } + + var utxo = UtxoRestoreEntryFFI() + // Match the existing AccountSpecFFI population pattern — + // cbindgen imports both tag enums as `UInt8` aliases, so + // assign the raw byte directly rather than constructing a + // `RawRepresentable`. + utxo.type_tag = UInt8(truncatingIfNeeded: account.accountType) + utxo.standard_tag = account.standardTag + utxo.account_index = account.accountIndex + utxo.registration_index = account.registrationIndex + utxo.key_class = account.keyClass + copyBytes(account.userIdentityId, into: &utxo.user_identity_id) + copyBytes(account.friendIdentityId, into: &utxo.friend_identity_id) + copyBytes(txid, into: &utxo.prev_txid) + utxo.vout = record.vout + utxo.value_duffs = record.amount + utxo.script_pubkey = scriptPtr + utxo.script_pubkey_len = UInt(scriptLen) + utxo.height = record.height + utxo.is_coinbase = record.isCoinbase + utxo.is_confirmed = record.isConfirmed + utxo.is_instantlocked = record.isInstantLocked + utxo.is_locked = record.isLocked + buf[written] = utxo + written += 1 + } + if written == 0 { + buf.deallocate() + return (nil, 0) + } + allocation.utxoArrays.append((buf, written)) + return (buf, written) + } + private func buildIdentityRestoreBuffer( identities: [PersistentIdentity], allocation: LoadAllocation @@ -2335,6 +2448,9 @@ private final class LoadAllocation { /// `cStringBuffers`; releasing this array doesn't touch the /// underlying strings. var cStringPointerArrays: [(UnsafeMutablePointer?>, Int)] = [] + /// Per-wallet `UtxoRestoreEntryFFI` arrays. The script bytes each + /// row references live in `scalarBuffers`. + var utxoArrays: [(UnsafeMutablePointer, Int)] = [] func release() { if let entries = entries { @@ -2366,6 +2482,10 @@ private final class LoadAllocation { for (ptr, _) in cStringPointerArrays { ptr.deallocate() } + for (ptr, count) in utxoArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 95c5ee0b2d9..b62119a1f84 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -635,6 +635,11 @@ struct SyncProgressRow: View { struct WalletRowView: View { let wallet: PersistentWallet @EnvironmentObject var platformState: AppState + /// Canonical Core-balance source. The previously-persisted + /// `PersistentWallet.balanceConfirmed`/etc. fields were removed — + /// Rust's in-memory account totals (via `accountBalances(for:)`) + /// are the single source of truth, mirroring `BalanceCardView`. + @EnvironmentObject var walletManager: PlatformWalletManager /// Per-wallet BLAST-synced platform-address balances. Mirrors /// `BalanceCardView` so the summary row sees the same balance as @@ -669,9 +674,32 @@ struct WalletRowView: View { } } + /// Per-account Core balances pulled from Rust's in-memory totals. + /// Computed once per body evaluation; both `totalCoreBalance` and + /// `balanceBreakdown()` read from this so we don't hit the FFI + /// four times per row. + private var coreBalances: [PlatformWalletManager.AccountBalance] { + walletManager.accountBalances(for: wallet.walletId) + } + + private var coreConfirmed: UInt64 { + coreBalances.reduce(0) { $0 + $1.confirmed } + } + + private var coreUnconfirmed: UInt64 { + coreBalances.reduce(0) { $0 + $1.unconfirmed } + } + + private var coreImmature: UInt64 { + coreBalances.reduce(0) { $0 + $1.immature } + } + + private var coreLocked: UInt64 { + coreBalances.reduce(0) { $0 + $1.locked } + } + private var totalCoreBalance: UInt64 { - wallet.balanceConfirmed + wallet.balanceUnconfirmed - + wallet.balanceImmature + wallet.balanceLocked + coreConfirmed + coreUnconfirmed + coreImmature + coreLocked } /// Combined wallet balance expressed in DASH. Core uses 1e8 @@ -727,17 +755,21 @@ struct WalletRowView: View { private func balanceBreakdown() -> String? { var parts: [String] = [] - if wallet.balanceConfirmed > 0 { - parts.append("\(formatBalance(wallet.balanceConfirmed)) confirmed") + let confirmed = coreConfirmed + let unconfirmed = coreUnconfirmed + let immature = coreImmature + let locked = coreLocked + if confirmed > 0 { + parts.append("\(formatBalance(confirmed)) confirmed") } - if wallet.balanceUnconfirmed > 0 { - parts.append("\(formatBalance(wallet.balanceUnconfirmed)) unconfirmed") + if unconfirmed > 0 { + parts.append("\(formatBalance(unconfirmed)) unconfirmed") } - if wallet.balanceImmature > 0 { - parts.append("\(formatBalance(wallet.balanceImmature)) immature") + if immature > 0 { + parts.append("\(formatBalance(immature)) immature") } - if wallet.balanceLocked > 0 { - parts.append("\(formatBalance(wallet.balanceLocked)) locked") + if locked > 0 { + parts.append("\(formatBalance(locked)) locked") } return parts.isEmpty ? nil : parts.joined(separator: " • ") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index 1052065ec98..ca646e90465 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -32,14 +32,15 @@ struct ReceiveAddressView: View { @State private var faucetStatus: String? @State private var isFaucetLoading = false - /// Lowest-indexed unused external address on the primary BIP44 - /// account. `PersistentCoreAddress` rows are populated by the Rust + /// Lowest-indexed external address on the primary BIP44 account + /// that has never received an inbound transaction. + /// `PersistentCoreAddress` rows are populated by the Rust /// `on_persist_account_address_pools_fn` callback at wallet creation /// (initial gap-limit fill), so they're available without a /// runtime FFI hop. private var nextCoreReceiveAddress: PersistentCoreAddress? { guard let account = primaryBip44Account else { return nil } - return firstUnusedAddress(in: account, poolTag: 0) + return firstUnreceivedAddress(in: account, poolTag: 0) } /// Lowest-indexed unused address on the primary PlatformPayment @@ -83,16 +84,20 @@ struct ReceiveAddressView: View { return nil } - /// Lowest-indexed unused address in the given pool on the given - /// account, or nil if the pool has no unused slots. - private func firstUnusedAddress( + /// Lowest-indexed address in the given pool on the given account + /// that has never received an inbound transaction. `PersistentTxo` + /// rows are created on the SPV inbound-UTXO path and only ever + /// flagged spent (never deleted), so `addr.txos.isEmpty` is a + /// reliable "never received" signal — strictly stronger than the + /// `isUsed` flag, which doesn't always survive sync edge cases. + private func firstUnreceivedAddress( in account: PersistentAccount, poolTag: UInt8 ) -> PersistentCoreAddress? { var best: PersistentCoreAddress? = nil for addr in account.coreAddresses { if addr.poolTypeTag != poolTag { continue } - if addr.isUsed { continue } + if !addr.txos.isEmpty { continue } if let current = best, current.addressIndex <= addr.addressIndex { continue } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 6ed019c2f57..1c853f35a08 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -161,8 +161,13 @@ struct SendTransactionView: View { // MARK: - Computed + /// Spendable Core balance, summed from Rust's in-memory per-account + /// totals. The persisted `PersistentWallet.balanceConfirmed` field + /// was removed; `accountBalances(for:)` is now the canonical + /// source (same path `BalanceCardView` uses). private var coreBalance: UInt64 { - wallet.balanceConfirmed + walletManager.accountBalances(for: wallet.walletId) + .reduce(0) { $0 + $1.confirmed } } private var shieldedBalance: UInt64 { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 5198f8a253e..d8e19e397ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1228,12 +1228,6 @@ struct WalletStorageDetailView: View { FieldRow(label: "Synced Height", value: "\(record.syncedHeight)") FieldRow(label: "Imported", value: record.isImported ? "Yes" : "No") } - Section("Balance") { - FieldRow(label: "Confirmed", value: "\(record.balanceConfirmed)") - FieldRow(label: "Unconfirmed", value: "\(record.balanceUnconfirmed)") - FieldRow(label: "Immature", value: "\(record.balanceImmature)") - FieldRow(label: "Locked", value: "\(record.balanceLocked)") - } Section("Relationships") { FieldRow(label: "Accounts", value: "\(record.accounts.count)") // Inverse of `PersistentIdentity.wallet`. Surfaces diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift index dc19df4b8cf..2d5684ab302 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift @@ -50,6 +50,69 @@ private func formatDuffs(_ duffs: UInt64) -> String { ?? String(format: "%.8f DASH", dash) } +/// Coarse classification of which underlying Rust variant carries the +/// account: `ManagedCoreFundsAccount`, `ManagedCoreKeysAccount`, or the +/// separate `ManagedPlatformAccount`. Drives the row badge so the +/// natural emptiness of keys-only rows (no balance, no UTXOs) reads as +/// intentional rather than a missing-data bug. +/// +/// Mapping mirrors the post-split account-collection layout in +/// `key-wallet/src/managed_account/managed_account_collection.rs`: +/// Standard BIP44/BIP32, CoinJoin, and DashPay receive/external sit in +/// the funds variant; identity / asset-lock / provider account slots +/// were promoted to the keys variant; PlatformPayment is its own type. +private enum AccountVariantKind { + case funds + case keys + case platform + + var label: String { + switch self { + case .funds: return "Funds" + case .keys: return "Keys" + case .platform: return "Platform" + } + } + + var color: Color { + switch self { + case .funds: return .green + case .keys: return .blue + case .platform: return .purple + } + } +} + +private func accountVariantKind(typeTag: UInt8) -> AccountVariantKind { + switch typeTag { + // 0 Standard, 1 CoinJoin, 12 DashpayReceiving, 13 DashpayExternal + case 0, 1, 12, 13: return .funds + // 14 PlatformPayment lives on `ManagedPlatformAccount`, a distinct + // type from the core funds/keys split. + case 14: return .platform + // Everything else (identity registration / topup / invitation / + // asset-lock / provider keys / identity auth) is keys-only — no + // UTXOs, no balance, by construction. + default: return .keys + } +} + +/// Capsule badge rendered alongside the account row label. Color +/// coding matches `AccountVariantKind.color`. +private struct AccountVariantBadge: View { + let kind: AccountVariantKind + + var body: some View { + Text(kind.label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(kind.color.opacity(0.18)) + .foregroundColor(kind.color) + .clipShape(Capsule()) + } +} + private func accountTypeName(typeTag: UInt8, standardTag: UInt8) -> String { switch typeTag { case 0: return standardTag == 0 ? "BIP44" : "BIP32" @@ -101,6 +164,9 @@ struct WalletMemoryExplorerView: View { @State private var identitySyncRunning = false @State private var identitySyncing = false @State private var identityTokenRows: [IdentityTokenSyncRow] = [] + @State private var atomicWalletIds: [Data] = [] + @State private var addressSyncConfig: PlatformWalletManager.PlatformAddressSyncConfigSnapshot? + @State private var identitySyncConfig: PlatformWalletManager.IdentitySyncConfigSnapshot? @State private var loadError: String? var body: some View { @@ -108,6 +174,7 @@ struct WalletMemoryExplorerView: View { spvSection addressSyncSection identityTokenSyncSection + managerLevelSection walletsSection if let loadError { Section { @@ -129,7 +196,13 @@ struct WalletMemoryExplorerView: View { Section("SPV Sync") { let p = walletManager.spvProgress KVRow(label: "State", value: p.overallState.label) - KVRow(label: "Progress", value: String(format: "%.1f%%", p.overallPercentage)) + // `overallPercentage` is a 0.0–1.0 fraction (the same value + // ContentView feeds straight into `ProgressView(value:)`), + // so multiply by 100 before formatting as a percent. + KVRow( + label: "Progress", + value: String(format: "%.1f%%", p.overallPercentage * 100) + ) if let h = p.headers { KVRow(label: "Headers", value: "\(h.currentHeight)/\(h.targetHeight)") } @@ -191,6 +264,55 @@ struct WalletMemoryExplorerView: View { } } + // MARK: - Manager-level diagnostics + + private var managerLevelSection: some View { + Section { + DisclosureGroup("Atomic Wallet IDs (\(atomicWalletIds.count))") { + if atomicWalletIds.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(atomicWalletIds, id: \.self) { wid in + Text(wid.map { String(format: "%02x", $0) }.joined()) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + DisclosureGroup("PlatformAddressSyncManager Config") { + if let cfg = addressSyncConfig { + KVRow(label: "Interval (s)", value: "\(cfg.intervalSeconds)") + KVRow(label: "Watch List Size", value: "\(cfg.watchListSize)") + KVRow(label: "Last Event Wallets", value: "\(cfg.lastEventWalletCount)") + KVRow( + label: "Last Event", + value: formatTimestamp(cfg.lastEventUnixSeconds) + ) + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + DisclosureGroup("IdentitySyncManager Config") { + if let cfg = identitySyncConfig { + KVRow(label: "Interval (s)", value: "\(cfg.intervalSeconds)") + KVRow(label: "Queue Depth", value: "\(cfg.queueDepth)") + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + } header: { + Text("Manager State") + } + } + // MARK: - Wallets private var walletsSection: some View { @@ -283,6 +405,9 @@ struct WalletMemoryExplorerView: View { } catch { errors.append("Token sync state: \(error.localizedDescription)") } + atomicWalletIds = walletManager.listWalletIdsAtomic() + addressSyncConfig = walletManager.platformAddressSyncConfigSnapshot() + identitySyncConfig = walletManager.identitySyncConfigSnapshot() if !errors.isEmpty { loadError = errors.joined(separator: "\n") } @@ -306,12 +431,35 @@ struct WalletMemoryDetailView: View { @State private var idLabels: [Identifier: String] = [:] @State private var loadError: String? + // Diagnostic sections (Phases 3, 4, 7). + @State private var coreState: PlatformWalletManager.CoreWalletStateSnapshot? + @State private var identityWalletState: PlatformWalletManager.IdentityWalletStateSnapshot? + @State private var providerState: PlatformWalletManager.PlatformAddressProviderStateSnapshot? + @State private var trackedAssetLocks: [PlatformWalletManager.TrackedAssetLockSnapshot] = [] + @State private var instantSendLocks: [Data] = [] + @State private var outOfWalletIds: [Data] = [] + @State private var walletIdentityRows: [PlatformWalletManager.WalletIdentityRow] = [] + var body: some View { Form { walletInfoSection + // PlatformWalletInfo metadata block (name / description / + // birth+synced+last-processed heights / total transactions / + // first loaded at) was removed: every meaningful field + // either duplicates `Core Wallet State` or reads "0/never" + // because nothing populates it (total_transactions is + // event-driven, first_loaded_at isn't stamped on this + // path). The Rust accessor + FFI wrapper are gone too. + coreStateSection + identityWalletStateSection + platformAddressProviderSection balanceSection - accountBalancesSection + fundsAccountBalancesSection + keysAccountBalancesSection summarySection + identityManagerSection + trackedAssetLocksSection + instantSendLocksSection identitiesSection watchedSection if let loadError { @@ -339,6 +487,55 @@ struct WalletMemoryDetailView: View { } } + // MARK: - Core wallet state + + private var coreStateSection: some View { + Section("Core Wallet State") { + if let s = coreState { + KVRow(label: "Synced Height", value: "\(s.syncedHeight)") + KVRow(label: "Last Processed", value: "\(s.lastProcessedHeight)") + KVRow(label: "Monitor Revision (max)", value: "\(s.monitorRevision)") + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Identity wallet scan state + + private var identityWalletStateSection: some View { + Section("Identity Wallet Scan State") { + if let s = identityWalletState { + KVRow(label: "Last Scanned Index", value: "\(s.lastScannedIndex)") + KVRow(label: "Scan Pending", value: s.scanPending ? "yes" : "no") + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Platform Address Provider state + + private var platformAddressProviderSection: some View { + Section("Platform Address Provider") { + if let s = providerState { + KVRow(label: "Initialized", value: s.initialized ? "yes" : "no") + KVRow(label: "Accounts Watched", value: "\(s.accountsWatched)") + KVRow(label: "Found Count", value: "\(s.foundCount)") + KVRow(label: "Known Balances", value: "\(s.knownBalancesCount)") + KVRow(label: "Watermark Height", value: "\(s.watermarkHeight)") + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + // MARK: - Balance private var balanceSection: some View { @@ -358,40 +555,221 @@ struct WalletMemoryDetailView: View { } // MARK: - Account Balances + // + // Funds and Keys variants are split into separate sections so the + // headline number on each row reads correctly: balance for funds + // (real money, summable), keys-used for keys (no balance by + // construction — the Rust-side `ManagedCoreKeysAccount` doesn't + // carry UTXOs). Platform-payment accounts (the third variant on + // `ManagedAccountCollection`) ride along on the funds section + // because they DO carry balance, just under a different in-memory + // type. + + /// Funds + Platform-payment accounts; rendered with the C/U/I/L + /// balance breakdown. + private var fundsAccountBalancesSection: some View { + let rows = accountBalances.filter { + accountVariantKind(typeTag: $0.typeTag) != .keys + } + return Section { + if rows.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(rows.enumerated()), id: \.offset) { _, acct in + NavigationLink { + AccountDrillDownView(walletId: walletId, balance: acct) + } label: { + fundsAccountRow(acct: acct) + } + } + } + } header: { + Text("Core Funds Accounts (\(rows.count))") + } + } + + /// Keys-only accounts (identity / asset-lock / provider). The + /// headline number is `keysUsed / keysTotal` rather than balance — + /// these accounts derive special-purpose keys and never carry + /// UTXOs. + private var keysAccountBalancesSection: some View { + let rows = accountBalances.filter { + accountVariantKind(typeTag: $0.typeTag) == .keys + } + return Section { + if rows.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(rows.enumerated()), id: \.offset) { _, acct in + NavigationLink { + AccountDrillDownView(walletId: walletId, balance: acct) + } label: { + keysAccountRow(acct: acct) + } + } + } + } header: { + Text("Core Keys Accounts (\(rows.count))") + } + } + + @ViewBuilder + private func fundsAccountRow( + acct: PlatformWalletManager.AccountBalance + ) -> some View { + let name = accountTypeName( + typeTag: acct.typeTag, + standardTag: acct.standardTag + ) + let kind = accountVariantKind(typeTag: acct.typeTag) + let total = acct.confirmed + acct.unconfirmed + acct.immature + acct.locked + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("\(name) #\(acct.index)") + .font(.system(.body, design: .monospaced)) + AccountVariantBadge(kind: kind) + Spacer() + Text(formatDuffs(total)) + .font(.caption) + .foregroundColor(.secondary) + } + HStack(spacing: 6) { + Text("C: \(formatDuffs(acct.confirmed))") + Text("·") + Text("U: \(formatDuffs(acct.unconfirmed))") + Text("·") + Text("I: \(formatDuffs(acct.immature))") + Text("·") + Text("L: \(formatDuffs(acct.locked))") + } + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func keysAccountRow( + acct: PlatformWalletManager.AccountBalance + ) -> some View { + let name = accountTypeName( + typeTag: acct.typeTag, + standardTag: acct.standardTag + ) + // Keys variants always badge as `Keys`; pinning the kind here + // avoids re-classifying inside the row. + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("\(name) #\(acct.index)") + .font(.system(.body, design: .monospaced)) + AccountVariantBadge(kind: .keys) + Spacer() + Text("\(acct.keysUsed) / \(acct.keysTotal) keys") + .font(.caption) + .foregroundColor(.secondary) + } + } + } - private var accountBalancesSection: some View { + // MARK: - Tracked asset locks + + private var trackedAssetLocksSection: some View { Section { - if accountBalances.isEmpty { - Text("No accounts") + if trackedAssetLocks.isEmpty { + Text("None") .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 + ForEach(Array(trackedAssetLocks.enumerated()), id: \.offset) { _, lock in 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)) + KVRow( + label: "Outpoint", + value: lock.outpointTxid.prefix(8).map { + String(format: "%02x", $0) + }.joined() + ":" + "\(lock.outpointVout)" + ) + KVRow(label: "Lock Type", value: trackedAssetLockTypeLabel(lock.lockType)) + KVRow(label: "Status", value: trackedAssetLockStatusLabel(lock.status)) + KVRow(label: "Reg Index", value: "\(lock.registrationIndex)") + KVRow(label: "InstantLock", value: lock.instantLockPresent ? "yes" : "no") + KVRow(label: "ChainLock Height", value: "\(lock.chainLockHeight)") } label: { + Text("Lock #\(trackedAssetLocks.firstIndex(where: { $0.outpointTxid == lock.outpointTxid && $0.outpointVout == lock.outpointVout }) ?? 0)") + .font(.system(.body, design: .monospaced)) + } + } + } + } header: { + Text("Tracked Asset Locks (\(trackedAssetLocks.count))") + } + } + + // MARK: - InstantSend lock txids + + private var instantSendLocksSection: some View { + Section { + if instantSendLocks.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(instantSendLocks, id: \.self) { txid in + Text(txid.map { String(format: "%02x", $0) }.joined()) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } header: { + Text("InstantSend Locks (\(instantSendLocks.count))") + } + } + + // MARK: - Identity Manager structure + + private var identityManagerSection: some View { + Section { + DisclosureGroup("Wallet Identities (\(walletIdentityRows.count))") { + if walletIdentityRows.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(walletIdentityRows.enumerated()), id: \.offset) { _, row in HStack { - Text("\(name) #\(acct.index)") + Text("#\(row.registrationIndex)") .font(.system(.body, design: .monospaced)) Spacer() - Text(formatDuffs(total)) - .font(.caption) - .foregroundColor(.secondary) + Text(row.identityId.map { String(format: "%02x", $0) }.joined()) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) } } } } + DisclosureGroup("Out-of-Wallet Identities (\(outOfWalletIds.count))") { + if outOfWalletIds.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(outOfWalletIds, id: \.self) { id in + Text(id.map { String(format: "%02x", $0) }.joined()) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } } header: { - Text("Core Account Balances (\(accountBalances.count))") + Text("Identity Manager Structure") } } @@ -530,12 +908,255 @@ struct WalletMemoryDetailView: View { idLabels[id] = label } } + coreState = walletManager.coreWalletState(for: walletId) + identityWalletState = walletManager.identityWalletState(for: walletId) + providerState = walletManager.platformAddressProviderState(for: walletId) + trackedAssetLocks = walletManager.trackedAssetLocks(for: walletId) + instantSendLocks = walletManager.instantSendLockTxids(for: walletId) + outOfWalletIds = walletManager.identityManagerOutOfWalletIds(for: walletId) + walletIdentityRows = walletManager.identityManagerWalletIdentities(for: walletId) if !errors.isEmpty { loadError = errors.joined(separator: "\n") } } } +// MARK: - Per-account drill-down view + +struct AccountDrillDownView: View { + let walletId: Data + let balance: PlatformWalletManager.AccountBalance + @EnvironmentObject var walletManager: PlatformWalletManager + + @State private var metadata: PlatformWalletManager.AccountMetadataSnapshot? + @State private var pools: [PlatformWalletManager.AccountAddressPool] = [] + @State private var utxos: [PlatformWalletManager.AccountUtxo] = [] + + /// Whether this account is the keys-only variant — drives whether + /// UTXO-related surfaces are shown. UTXOs are exclusive to the + /// `ManagedCoreFundsAccount` Rust variant; keys-only accounts + /// (identity / asset-lock / provider) never carry them. + private var isKeysAccount: Bool { + accountVariantKind(typeTag: balance.typeTag) == .keys + } + + var body: some View { + Form { + // Balance + UTXOs both live on the funds variant only. + // Suppress them on keys-only accounts so the drill-down + // doesn't render five zero rows that look like missing + // data rather than "by design". + if !isKeysAccount { + balanceHeaderSection + } + metadataSection + addressPoolsSection + if !isKeysAccount { + utxosSection + } + // Per-account in-memory transaction list intentionally + // omitted: `keep_txs_in_memory` is off and tx history is + // delivered through the event channel rather than stored + // on `ManagedCoreFundsAccount.transactions`. The Rust-side + // `account_transactions_blocking` accessor and its FFI / + // Swift wrapper still exist (return empty by design) for + // builds that flip the feature on. + } + .navigationTitle( + accountTypeName(typeTag: balance.typeTag, standardTag: balance.standardTag) + + " #\(balance.index)" + ) + .navigationBarTitleDisplayMode(.inline) + .onAppear { load() } + } + + private var balanceHeaderSection: some View { + Section("Balance") { + KVRow(label: "Confirmed", value: formatDuffs(balance.confirmed)) + KVRow(label: "Unconfirmed", value: formatDuffs(balance.unconfirmed)) + KVRow(label: "Immature", value: formatDuffs(balance.immature)) + KVRow(label: "Locked", value: formatDuffs(balance.locked)) + KVRow( + label: "Total", + value: formatDuffs( + balance.confirmed + balance.unconfirmed + + balance.immature + balance.locked + ) + ) + } + } + + private var metadataSection: some View { + Section("Account Metadata") { + if let m = metadata { + // `totalTransactions` is intentionally not surfaced — + // it counts the in-memory transaction map, which is + // empty by design when `keep_txs_in_memory` is off. + // "Watch Only" and "Custom Name" rows were dropped in + // lockstep with upstream removing those fields from + // the underlying `ManagedCore*Account` variants — + // watch-only is wallet-level now, custom names are + // gone entirely. + if !isKeysAccount { + // Hide "Total UTXOs" on keys-only accounts: they + // never carry UTXOs, so the row would always read + // 0 and add noise. + KVRow(label: "Total UTXOs", value: "\(m.totalUtxos)") + } + KVRow(label: "Monitor Revision", value: "\(m.monitorRevision)") + } else { + Text("Unavailable") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var addressPoolsSection: some View { + Section { + if pools.isEmpty { + Text("No pools") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(pools.enumerated()), id: \.offset) { idx, pool in + DisclosureGroup { + KVRow(label: "Gap Limit", value: "\(pool.gapLimit)") + KVRow( + label: "Last Used Index", + value: pool.lastUsedIndex < 0 + ? "—" + : "\(pool.lastUsedIndex)" + ) + KVRow(label: "Address Count", value: "\(pool.addresses.count)") + ForEach(Array(pool.addresses.enumerated()), id: \.offset) { _, info in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("idx \(info.addressIndex)") + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + Spacer() + Text(info.isUsed ? "used" : "unused") + .font(.caption2) + .foregroundColor(info.isUsed ? .accentColor : .secondary) + } + // Encoded address — the prominent line + // for the row. + Text(info.address.isEmpty ? "—" : info.address) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + // Public-key bytes (hex). Empty when + // the pool didn't retain the + // derivation source — falls back to + // the 20-byte pubkey-hash so the row + // always carries some cryptographic + // identity for the user. + let pkHex = (info.publicKeyBytes.isEmpty + ? info.pubkeyHash + : info.publicKeyBytes + ).map { String(format: "%02x", $0) }.joined() + let pkLabel = info.publicKeyBytes.isEmpty + ? "hash160: \(pkHex)" + : "pubkey: \(pkHex)" + Text(pkLabel) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + } + } label: { + Text("Pool \(idx) (\(addressPoolTypeLabel(pool.poolType)))") + .font(.system(.body, design: .monospaced)) + } + } + } + } header: { + Text("Address Pools (\(pools.count))") + } + } + + private var utxosSection: some View { + Section { + if utxos.isEmpty { + Text("None") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(Array(utxos.enumerated()), id: \.offset) { _, u in + DisclosureGroup { + KVRow(label: "Value", value: formatDuffs(u.valueDuffs)) + KVRow(label: "Height", value: "\(u.height)") + KVRow(label: "Locked", value: u.isLocked ? "yes" : "no") + KVRow(label: "Script Len", value: "\(u.scriptPubkey.count)") + } label: { + VStack(alignment: .leading, spacing: 1) { + Text( + u.outpointTxid.map { String(format: "%02x", $0) }.joined() + + ":" + "\(u.outpointVout)" + ) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Text(formatDuffs(u.valueDuffs)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } header: { + Text("UTXOs (\(utxos.count))") + } + } + + private func load() { + metadata = walletManager.accountMetadata(for: walletId, balance: balance) + pools = walletManager.accountAddressPools(for: walletId, balance: balance) + utxos = walletManager.accountUtxos(for: walletId, balance: balance) + // Tx history is event-driven and not held in memory; skip the + // accessor here — see the comment on the body's omitted + // `transactionsSection`. + } +} + +// MARK: - Helper labels + +private func addressPoolTypeLabel(_ tag: UInt8) -> String { + switch tag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Absent" + case 3: return "AbsentHardened" + default: return "Unknown(\(tag))" + } +} + +private func trackedAssetLockTypeLabel(_ tag: UInt8) -> String { + switch tag { + case 0: return "IdentityRegistration" + case 1: return "IdentityTopUp" + case 2: return "IdentityTopUpNotBound" + case 3: return "IdentityInvitation" + case 4: return "AssetLockAddressTopUp" + case 5: return "AssetLockShieldedAddressTopUp" + default: return "Unknown(\(tag))" + } +} + +private func trackedAssetLockStatusLabel(_ tag: UInt8) -> String { + switch tag { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSendLocked" + case 3: return "ChainLocked" + default: return "Unknown(\(tag))" + } +} + // MARK: - Per-identity detail view struct WalletMemoryIdentityDetailView: View { From 7e802b4f05f18cfe0af6834f639e135f9c3c3202 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 07:30:19 +0700 Subject: [PATCH 02/10] fix(swift-sdk): address CodeRabbit feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accountTransactions: guard non-negative pageOffset/pageLimit before the Int → UInt cast so misuse returns [] instead of trapping. - loadWalletList: stop synthesizing entry.last_processed_height from PersistentWallet.syncedHeight. The two can diverge and overstating processed progress would let SPV skip blocks on restore. Send 0 (Rust treats as unknown and falls back to the from_wallet seed) until a real watermark column exists. - WalletRowView: snapshot accountBalances(for:) once per body render via a CoreBalanceTotals tuple instead of recomputing through four separate computed properties + balanceBreakdown(). Cuts ~12 FFI roundtrips per row down to one. - SendTransactionView: same hoist — capture coreBalance at the top of body and thread through availableSources / balance(for:); helpers now take it as a parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWalletManagerDiagnostics.swift | 11 ++- .../PlatformWalletPersistenceHandler.swift | 17 ++-- .../Core/Views/CoreContentView.swift | 95 +++++++++---------- .../Core/Views/SendTransactionView.swift | 32 +++++-- 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift index f48556f54f0..2616915d0b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift @@ -398,7 +398,16 @@ extension PlatformWalletManager { pageOffset: Int = 0, pageLimit: Int = 0 ) -> [AccountTransaction] { - guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { return [] } + // `Int → UInt` traps on negative input; guard up front so a + // misuse (e.g. negative offset) returns an empty result rather + // than crashing. `pageLimit == 0` is reserved for "no limit" + // by the Rust accessor, so 0 is a valid lower bound for both. + guard isConfigured, + handle != NULL_HANDLE, + walletId.count == 32, + pageOffset >= 0, + pageLimit >= 0 + else { return [] } var spec = makeAccountSpec(from: balance) var outTxs: UnsafePointer? = nil var outCount: UInt = 0 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 2f9a46d3a51..803e47812c2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2068,16 +2068,17 @@ public class PlatformWalletPersistenceHandler { entry.platform_last_known_recent_block = syncState?.lastKnownRecentBlock ?? 0 entry.identities = identitiesBuffer.map { UnsafePointer($0) } entry.identities_count = UInt(sortedIdentities.count) - // Core-chain sync metadata. `PersistentWallet` doesn't - // carry a separate `lastProcessedHeight` column today — - // re-use `syncedHeight` for both. The Rust loader treats - // zero as "unknown" and falls back to the - // `birth_height - 1` seed from `ManagedWalletInfo::from_wallet`, - // so freshly-recovered wallets without a sync watermark - // round-trip cleanly. + // Core-chain sync metadata. `last_processed_height` is + // intentionally NOT synthesized from `syncedHeight` — the + // two can diverge, and overstating processed progress on + // restore would let SPV skip blocks it still needs to + // replay. Persist a real watermark column to feed it; for + // now send 0 so the Rust loader's + // `ManagedWalletInfo::from_wallet` seed + // (`birth_height.saturating_sub(1)`) survives untouched. entry.birth_height = w.birthHeight entry.synced_height = w.syncedHeight - entry.last_processed_height = w.syncedHeight + entry.last_processed_height = 0 entry.last_synced = w.lastSynced // Persisted unspent UTXOs for this wallet. The SPV inbound diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index b62119a1f84..89e4aeed275 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -674,45 +674,41 @@ struct WalletRowView: View { } } - /// Per-account Core balances pulled from Rust's in-memory totals. - /// Computed once per body evaluation; both `totalCoreBalance` and - /// `balanceBreakdown()` read from this so we don't hit the FFI - /// four times per row. - private var coreBalances: [PlatformWalletManager.AccountBalance] { + /// One-shot snapshot of the wallet's per-account Core balances. + /// `accountBalances(for:)` is a blocking FFI call; the prior + /// shape (a `coreBalances` computed property + four `coreX` sums) + /// hit the FFI four times per render and again from + /// `balanceBreakdown`. Capturing in `body` and threading the + /// tuple through reduces every render to a single FFI roundtrip. + private typealias CoreBalanceTotals = ( + confirmed: UInt64, + unconfirmed: UInt64, + immature: UInt64, + locked: UInt64 + ) + + private func coreBalanceTotals() -> CoreBalanceTotals { walletManager.accountBalances(for: wallet.walletId) + .reduce(into: (UInt64(0), UInt64(0), UInt64(0), UInt64(0))) { acc, b in + acc.0 += b.confirmed + acc.1 += b.unconfirmed + acc.2 += b.immature + acc.3 += b.locked + } } - private var coreConfirmed: UInt64 { - coreBalances.reduce(0) { $0 + $1.confirmed } - } - - private var coreUnconfirmed: UInt64 { - coreBalances.reduce(0) { $0 + $1.unconfirmed } - } - - private var coreImmature: UInt64 { - coreBalances.reduce(0) { $0 + $1.immature } - } - - private var coreLocked: UInt64 { - coreBalances.reduce(0) { $0 + $1.locked } - } - - private var totalCoreBalance: UInt64 { - coreConfirmed + coreUnconfirmed + coreImmature + coreLocked + private static func sumCoreBalance(_ totals: CoreBalanceTotals) -> UInt64 { + totals.confirmed + totals.unconfirmed + totals.immature + totals.locked } - /// Combined wallet balance expressed in DASH. Core uses 1e8 - /// duffs/DASH; Platform uses 1e11 credits/DASH. - private var combinedDashAmount: Double { - Double(totalCoreBalance) / 100_000_000.0 + /// Combined wallet balance expressed in DASH for a precomputed + /// totals tuple. Core uses 1e8 duffs/DASH; Platform uses 1e11 + /// credits/DASH. + private func combinedDashAmount(coreTotal: UInt64) -> Double { + Double(coreTotal) / 100_000_000.0 + Double(platformBalance) / 100_000_000_000.0 } - private var hasAnyBalance: Bool { - totalCoreBalance > 0 || platformBalance > 0 - } - private var walletIdShort: String { let hex = wallet.walletId.prefix(6) .map { String(format: "%02x", $0) } @@ -753,23 +749,19 @@ struct WalletRowView: View { return String(format: "%.4f DASH", dash) } - private func balanceBreakdown() -> String? { + private func balanceBreakdown(_ totals: CoreBalanceTotals) -> String? { var parts: [String] = [] - let confirmed = coreConfirmed - let unconfirmed = coreUnconfirmed - let immature = coreImmature - let locked = coreLocked - if confirmed > 0 { - parts.append("\(formatBalance(confirmed)) confirmed") + if totals.confirmed > 0 { + parts.append("\(formatBalance(totals.confirmed)) confirmed") } - if unconfirmed > 0 { - parts.append("\(formatBalance(unconfirmed)) unconfirmed") + if totals.unconfirmed > 0 { + parts.append("\(formatBalance(totals.unconfirmed)) unconfirmed") } - if immature > 0 { - parts.append("\(formatBalance(immature)) immature") + if totals.immature > 0 { + parts.append("\(formatBalance(totals.immature)) immature") } - if locked > 0 { - parts.append("\(formatBalance(locked)) locked") + if totals.locked > 0 { + parts.append("\(formatBalance(totals.locked)) locked") } return parts.isEmpty ? nil : parts.joined(separator: " • ") } @@ -788,7 +780,14 @@ struct WalletRowView: View { }() var body: some View { - VStack(alignment: .leading, spacing: 6) { + // Single FFI snapshot per render — `coreBalanceTotals()` calls + // `walletManager.accountBalances(for:)` once; everything below + // reads from `core` / `coreTotal` / `hasAny` instead of + // re-invoking the accessor. + let core = coreBalanceTotals() + let coreTotal = Self.sumCoreBalance(core) + let hasAny = coreTotal > 0 || platformBalance > 0 + return VStack(alignment: .leading, spacing: 6) { // Header: label (+ status badges) and total Core balance. HStack(alignment: .firstTextBaseline) { HStack(spacing: 6) { @@ -802,10 +801,10 @@ struct WalletRowView: View { } } Spacer() - Text(hasAnyBalance ? formatDash(combinedDashAmount) : "Empty") + Text(hasAny ? formatDash(combinedDashAmount(coreTotal: coreTotal)) : "Empty") .font(.subheadline) .fontWeight(.medium) - .foregroundColor(hasAnyBalance ? .primary : .secondary) + .foregroundColor(hasAny ? .primary : .secondary) } // Row 1: network + created date. @@ -822,7 +821,7 @@ struct WalletRowView: View { WalletInfoRow( icon: "bitcoinsign.circle", iconColor: .green, - text: balanceBreakdown() ?? "No Core balance" + text: balanceBreakdown(core) ?? "No Core balance" ) // Row 3: account + identity counts. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 1c853f35a08..90ea08db872 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -19,7 +19,14 @@ struct SendTransactionView: View { } var body: some View { - NavigationStack { + // Snapshot Core balance once per render — `coreBalance` goes + // through a blocking FFI call (`accountBalances(for:)`); the + // prior shape re-evaluated it for the summary row, the source + // list, the per-source balance, and `availableSources`, + // hitting the FFI repeatedly on a typing-heavy form. + let coreBalance = coreBalanceSnapshot() + let sources = availableSources(coreBalance: coreBalance) + return NavigationStack { Form { // Recipient Section("Recipient") { @@ -49,9 +56,9 @@ struct SendTransactionView: View { } // Fund Source - if !availableSources.isEmpty { + if !sources.isEmpty { Section("Send From") { - ForEach(availableSources) { source in + ForEach(sources) { source in Button { viewModel.selectedSource = source viewModel.updateFlow() @@ -63,7 +70,9 @@ struct SendTransactionView: View { Text(source.rawValue) .foregroundColor(.primary) Spacer() - Text(formatBalance(balance(for: source))) + Text(formatBalance( + balance(for: source, coreBalance: coreBalance) + )) .font(.caption) .foregroundColor(.secondary) if viewModel.selectedSource == source { @@ -164,8 +173,10 @@ struct SendTransactionView: View { /// Spendable Core balance, summed from Rust's in-memory per-account /// totals. The persisted `PersistentWallet.balanceConfirmed` field /// was removed; `accountBalances(for:)` is now the canonical - /// source (same path `BalanceCardView` uses). - private var coreBalance: UInt64 { + /// source (same path `BalanceCardView` uses). Exposed as a + /// function rather than a computed property so callers can + /// snapshot once per render and thread the value through. + private func coreBalanceSnapshot() -> UInt64 { walletManager.accountBalances(for: wallet.walletId) .reduce(0) { $0 + $1.confirmed } } @@ -178,7 +189,7 @@ struct SendTransactionView: View { wallet.identities.reduce(UInt64(0)) { $0 + UInt64(bitPattern: $1.balance) } } - private var availableSources: [FundSource] { + private func availableSources(coreBalance: UInt64) -> [FundSource] { viewModel.availableSources( coreBalance: coreBalance, shieldedBalance: shieldedBalance, @@ -186,7 +197,7 @@ struct SendTransactionView: View { ) } - private func balance(for source: FundSource) -> UInt64 { + private func balance(for source: FundSource, coreBalance: UInt64) -> UInt64 { switch source { case .core: return coreBalance case .shielded: return shieldedBalance @@ -195,8 +206,11 @@ struct SendTransactionView: View { } /// Auto-select the first available source when address type changes. + /// Snapshots `coreBalance` once for the duration of this call so + /// the underlying FFI accessor isn't hit twice. private func autoSelectSource() { - if let first = availableSources.first { + let coreBalance = coreBalanceSnapshot() + if let first = availableSources(coreBalance: coreBalance).first { viewModel.selectedSource = first viewModel.updateFlow() } From 46d42fbadda57a29c5592096e3e2f380bbe35e59 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 08:45:50 +0700 Subject: [PATCH 03/10] fix(swift-sdk): address round-2 thepastaclaw review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking: - persistence.rs account loader: skip-and-continue on legacy IdentityAuthentication{Ecdsa,Bls} tags instead of `?`-propagating the err. Previously a single such row aborted load() for every wallet on every launch — same failure mode the UTXO loop already handled. tracing::warn the skip so operators can detect it. - PlatformWalletPersistenceHandler: address-balance buffer was sized to cachedBalances.count but indexed by enumerate(), leaving uninit slots when a row had hash.count != 20. Rust read the published count and saw uninit memory — UB. Switched to a `written` counter + per-row write so the published count matches actual writes. - load_from_persistor: rolled back insert_wallet on the recomputed-id mismatch and the initialize_from_persisted failure paths so a mid-load error doesn't leave an orphan registration in wallet_manager that breaks the manager's invariant with self.wallets. Suggestions: - Reverted entry.last_processed_height to w.syncedHeight (was 0). Per the upstream impl, update_balance feeds last_processed_height into ManagedCoreFundsAccount::update_balance for coinbase-maturity classification — leaving it at birth_height-1 mis-buckets matured outputs. For non-pruning SPV wallets the two heights advance in lockstep, so syncedHeight is the correct stop-gap until a dedicated PersistentWallet column lands. - UTXO loader: per-skip tracing::warn with the reason, plus a single rollup with routed/dropped counters split by category (account_type / bad_txid / bad_script / no_account). - IdentityKeyRestoreFFI: invalid persisted discriminants now fall back to UInt8.max instead of 0. The prior `?? 0` silently coerced parse failures into valid ECDSA / AUTHENTICATION / MASTER values; UInt8.max forces Rust's KeyType/Purpose/SecurityLevel try_from to reject so build_identity_public_keys drops the row cleanly. Nitpicks: - Aligned manager_diagnostics.rs free fns on Box::from_raw + slice_from_raw_parts_mut (was Vec::from_raw_parts) so dealloc matches the producer's Box::into_raw(into_boxed_slice()) shape. - Fixed comment claiming update_balance uses last_processed_height — it actually reads synced_height (the parameter naming on ManagedCoreFundsAccount::update_balance is historical). tracing dep added to platform-wallet-ffi/Cargo.toml. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + packages/rs-platform-wallet-ffi/Cargo.toml | 5 + .../src/manager_diagnostics.rs | 21 ++++- .../rs-platform-wallet-ffi/src/persistence.rs | 91 ++++++++++++++++--- .../rs-platform-wallet/src/manager/load.rs | 33 +++++-- .../PlatformWalletPersistenceHandler.swift | 84 +++++++++++------ 6 files changed, 182 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce0f8d78e0b..34b9142d2b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4901,6 +4901,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing", "zeroize", ] diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 574448b0403..76fac52dce2 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -36,6 +36,11 @@ bincode = { version = "=2.0.1" } # Hex used for error diagnostics that include a wallet_id. hex = "0.4" +# Persistence loader emits structured warnings for skipped / +# corrupt rows so operators can detect snapshot drift without a +# native debugger attached. +tracing = "0.1" + # anyhow surfaces from `KeyType::try_from` / `Purpose::try_from` # / `SecurityLevel::try_from` in dpp; we need the From impl in # `error.rs` so `unwrap_result_or_return!` can absorb it. diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 84c6f95b6bf..85f741ada1c 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -78,7 +78,14 @@ pub unsafe extern "C" fn platform_wallet_manager_list_wallet_ids( pub unsafe extern "C" fn platform_wallet_manager_free_wallet_ids(bytes: *mut u8, count: usize) { if !bytes.is_null() && count > 0 { let total = count * 32; - drop(Vec::from_raw_parts(bytes, total, total)); + // `Box::from_raw(slice_from_raw_parts_mut(...))` mirrors the + // producer side (`Box::into_raw(vec.into_boxed_slice())`) + // exactly. Using `Vec::from_raw_parts` would technically work + // because `into_boxed_slice` shrinks capacity to length, but + // its documented contract is "originally allocated by Vec"; + // the boxed-slice form is unambiguous and matches the rest + // of this file. + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, total)); } } @@ -335,9 +342,12 @@ pub unsafe extern "C" fn platform_wallet_instant_send_locks( #[no_mangle] pub unsafe extern "C" fn platform_wallet_instant_send_locks_free(bytes: *mut u8, count: usize) { + // See `platform_wallet_manager_free_wallet_ids` for the rationale + // on the `Box::from_raw` / `slice_from_raw_parts_mut` form (matches + // the producer's `Box::into_raw(vec.into_boxed_slice())`). if !bytes.is_null() && count > 0 { let total = count * 32; - drop(Vec::from_raw_parts(bytes, total, total)); + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, total)); } } @@ -592,10 +602,9 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let slice = std::slice::from_raw_parts(utxos, count); for entry in slice { if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { - drop(Vec::from_raw_parts( + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( entry.script_pubkey, entry.script_pubkey_len, - entry.script_pubkey_len, )); } } @@ -719,7 +728,9 @@ pub unsafe extern "C" fn platform_wallet_identity_manager_out_of_wallet_ids_free ) { if !bytes.is_null() && count > 0 { let total = count * 32; - drop(Vec::from_raw_parts(bytes, total, total)); + // Match the producer's `Box::into_raw(vec.into_boxed_slice())` + // shape — see `platform_wallet_manager_free_wallet_ids`. + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, total)); } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 3ee77af7604..53afe5ad345 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1166,7 +1166,23 @@ fn build_wallet_start_state( unsafe { slice::from_raw_parts(entry.accounts, entry.accounts_count) } }; for spec in specs { - let account_type = account_type_from_spec(spec)?; + // Skip-and-continue on legacy `IdentityAuthentication{Ecdsa,Bls}` + // rows — those `AccountTypeTagFFI` discriminants are still ABI- + // valid but their upstream `AccountType` variants were removed, + // so `account_type_from_spec` deliberately returns `Err` for + // them. Propagating that with `?` would abort the entire + // `load()` (every wallet, every launch) the moment a single + // such row exists in SwiftData. Treating it as recoverable + // snapshot drift matches how the UTXO loop a few lines below + // handles the same failure mode. + let Ok(account_type) = account_type_from_spec(spec) else { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + type_tag = ?spec.type_tag, + "load: skipping persisted account row with unmappable AccountType" + ); + continue; + }; let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; let (account_xpub, _): (ExtendedPubKey, usize) = @@ -1215,7 +1231,18 @@ fn build_wallet_start_state( } else { unsafe { slice::from_raw_parts(entry.utxos, entry.utxos_count) } }; + // Track each skip reason separately so a non-zero `dropped` value + // is debuggable without a native trace. The four categories have + // very different operational meanings — corruption (bad txid / + // unrenderable script), legitimate drift (no matching account), + // and ABI-only-present-tag (unmappable type for keys-only / legacy + // identity-auth rows). Each emits a `tracing::warn!` so a host + // running a subscriber sees the breakdown in real time. let mut routed = 0usize; + let mut dropped_account_type = 0usize; + let mut dropped_bad_txid = 0usize; + let mut dropped_bad_script = 0usize; + let mut dropped_no_account = 0usize; for u in utxo_entries { // Bring `Hash` into scope locally so `Txid::from_slice` is // available — matches the pattern used elsewhere in this @@ -1237,13 +1264,24 @@ fn build_wallet_start_state( account_xpub_bytes_len: 0, }; // Tags that don't map to any current `AccountType` (e.g. - // legacy `IdentityAuthentication{Ecdsa,Bls}`) are silently - // skipped — the SwiftData row can't be restored cleanly and - // the next sync will recover any funds it represents. + // legacy `IdentityAuthentication{Ecdsa,Bls}`) are skipped — + // the SwiftData row can't be restored cleanly and the next + // sync will recover any funds it represents. let Ok(account_type) = account_type_from_spec(&spec) else { + dropped_account_type += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + type_tag = ?u.type_tag, + "load: skipping persisted UTXO with unmappable AccountType" + ); continue; }; let Ok(txid) = dashcore::Txid::from_slice(&u.prev_txid) else { + dropped_bad_txid += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + "load: skipping persisted UTXO with malformed txid bytes" + ); continue; }; let outpoint = dashcore::OutPoint { @@ -1252,6 +1290,13 @@ fn build_wallet_start_state( }; let script_pubkey = dashcore::ScriptBuf::from_bytes(script_bytes.to_vec()); let Ok(address) = dashcore::Address::from_script(&script_pubkey, network) else { + dropped_bad_script += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + txid = %txid, + vout = u.vout, + "load: skipping persisted UTXO with un-decodable script_pubkey" + ); continue; }; let txout = dashcore::TxOut { @@ -1316,11 +1361,31 @@ fn build_wallet_start_state( if let Some(funds_account) = target_funds { funds_account.utxos.insert(utxo.outpoint, utxo); routed += 1; + } else { + dropped_no_account += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + ?account_type, + "load: skipping persisted UTXO with no matching funds account in snapshot" + ); } - // Snapshot drift (UTXO references an account that didn't - // make it into `entry.accounts`, or the account is keys-only - // / PlatformPayment) is silently skipped — re-sync will - // recover the row. + } + let dropped = + dropped_account_type + dropped_bad_txid + dropped_bad_script + dropped_no_account; + if dropped > 0 { + // Surface a single rollup line so operators see the totals + // even with `tracing` set to ERROR-only (the per-row warns + // above are the breakdown). + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + routed, + dropped, + dropped_account_type, + dropped_bad_txid, + dropped_bad_script, + dropped_no_account, + "load: persisted UTXO restore completed with skipped rows" + ); } // Recompute balances from the freshly-loaded UTXO set. Raw @@ -1328,9 +1393,13 @@ fn build_wallet_start_state( // path that keeps the per-account `balance` field in sync, so // the per-account confirmed/unconfirmed/immature/locked totals // and the wallet-level rollup stay zero unless we tell the info - // to reread them. `update_balance` walks every funds account, - // recomputes from `utxos` against `metadata.last_processed_height`, - // and sums into `wallet_info.balance`. The lock-free + // to reread them. `update_balance` walks every funds account + // and recomputes from `utxos` against the wallet's + // `metadata.synced_height` (passed through to + // `ManagedCoreFundsAccount::update_balance` as the + // `last_processed_height` parameter — that's the maturity + // baseline upstream uses; the parameter naming is historical), + // then sums into `wallet_info.balance`. The lock-free // `Arc` the UI reads is mirrored in // `manager::load::load_from_persistor` (`WalletBalance::set` is // `pub(crate)` to platform-wallet). diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c1be66159e6..bae56759962 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -82,6 +82,15 @@ impl PlatformWalletManager

{ tracked_asset_locks, }; + // Insert into `wallet_manager` first so we have a wallet + // handle to validate against, then either keep the + // registration or roll it back. The two failure modes + // below — recomputed id mismatch and platform-address + // restore — used to leave the wallet half-registered: + // present in `wallet_manager` but absent from + // `self.wallets`, which broke the manager's invariant + // that the two collections describe the same set and + // poisoned any retry path. let wallet_id = { let mut wm = self.wallet_manager.write().await; wm.insert_wallet(wallet, platform_info).map_err(|e| { @@ -93,6 +102,12 @@ impl PlatformWalletManager

{ }; if wallet_id != expected_wallet_id { + // Roll back the insert before bailing — the wallet + // we just registered isn't the one the snapshot + // claimed it was, and leaving it in `wallet_manager` + // would collide on the next retry. + let mut wm = self.wallet_manager.write().await; + let _ = wm.remove_wallet(&wallet_id); return Err(PlatformWalletError::WalletCreation(format!( "Persisted wallet id {} does not match recomputed id {}", hex::encode(expected_wallet_id), @@ -116,17 +131,21 @@ impl PlatformWalletManager

{ // Initialize the platform-address provider. If the snapshot // carried a slice for this wallet, restore it directly; // otherwise do a fresh scan from the live wallet manager. + // Roll back the `insert_wallet` on failure so the caller + // can retry without stepping over a stale registration. if let Some(persisted) = platform_addresses.remove(&wallet_id) { - platform_wallet + if let Err(e) = platform_wallet .platform() .initialize_from_persisted(persisted) .await - .map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to restore platform address state: {}", - e - )) - })?; + { + let mut wm = self.wallet_manager.write().await; + let _ = wm.remove_wallet(&wallet_id); + return Err(PlatformWalletError::WalletCreation(format!( + "Failed to restore platform address state: {}", + e + ))); + } } else { platform_wallet.platform().initialize().await; } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 803e47812c2..1a6a67c5a09 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2003,40 +2003,53 @@ public class PlatformWalletPersistenceHandler { } let cachedBalances = loadCachedBalancesOnQueue(walletId: w.walletId) + // Compact-write into the buffer with a `written` counter so + // a malformed row (`hash.count != 20`) doesn't leave an + // uninitialized slot in the published slice. Rust reads + // exactly `entry.platform_address_balances_count` entries + // from the pointer; any uninit slot would be undefined + // behaviour. Same pattern the UTXO loader below uses. let addressBalancesBuffer: UnsafeMutablePointer? + let addressBalancesWritten: Int if cachedBalances.isEmpty { addressBalancesBuffer = nil + addressBalancesWritten = 0 } else { let buf = UnsafeMutablePointer.allocate( capacity: cachedBalances.count ) - for (j, cached) in cachedBalances.enumerated() { + var written = 0 + for cached in cachedBalances { let (addressType, hash, balance, nonce, accountIndex, addressIndex) = cached - guard hash.count == 20 else { - continue - } + guard hash.count == 20 else { continue } var hashTuple: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 ) = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - if hash.count == 20 { - withUnsafeMutableBytes(of: &hashTuple) { raw in - raw.copyBytes(from: hash) - } + withUnsafeMutableBytes(of: &hashTuple) { raw in + raw.copyBytes(from: hash) } - buf[j] = AddressBalanceEntryFFI( + buf[written] = AddressBalanceEntryFFI( address: PlatformAddressFFI(address_type: addressType, hash: hashTuple), balance: balance, nonce: nonce, account_index: accountIndex, address_index: addressIndex ) + written += 1 + } + if written == 0 { + buf.deallocate() + addressBalancesBuffer = nil + addressBalancesWritten = 0 + } else { + addressBalancesBuffer = buf + addressBalancesWritten = written + allocation.addressBalanceArrays.append((buf, written)) } - addressBalancesBuffer = buf - allocation.addressBalanceArrays.append((buf, cachedBalances.count)) } let syncState = w.network.flatMap { loadCachedSyncStateOnQueue(network: $0) } @@ -2062,23 +2075,26 @@ public class PlatformWalletPersistenceHandler { entry.accounts = accountsBuffer.map { UnsafePointer($0) } entry.accounts_count = UInt(sortedAccounts.count) entry.platform_address_balances = addressBalancesBuffer.map { UnsafePointer($0) } - entry.platform_address_balances_count = UInt(cachedBalances.count) + entry.platform_address_balances_count = UInt(addressBalancesWritten) entry.platform_sync_height = syncState?.syncHeight ?? 0 entry.platform_sync_timestamp = syncState?.syncTimestamp ?? 0 entry.platform_last_known_recent_block = syncState?.lastKnownRecentBlock ?? 0 entry.identities = identitiesBuffer.map { UnsafePointer($0) } entry.identities_count = UInt(sortedIdentities.count) - // Core-chain sync metadata. `last_processed_height` is - // intentionally NOT synthesized from `syncedHeight` — the - // two can diverge, and overstating processed progress on - // restore would let SPV skip blocks it still needs to - // replay. Persist a real watermark column to feed it; for - // now send 0 so the Rust loader's - // `ManagedWalletInfo::from_wallet` seed - // (`birth_height.saturating_sub(1)`) survives untouched. + // Core-chain sync metadata. `PersistentWallet` doesn't + // carry a separate `lastProcessedHeight` column today; + // for non-pruning SPV wallets the two heights advance in + // lockstep at runtime, so re-using `syncedHeight` keeps + // the restored wallet aligned with the runtime invariant. + // Sending `0` here would leave `metadata.last_processed_height` + // at `birth_height - 1` after restore, which mis-buckets + // matured coinbase outputs as immature in + // `update_balance` until SPV next advances. The proper + // fix is a dedicated column on `PersistentWallet` — + // tracked separately. entry.birth_height = w.birthHeight entry.synced_height = w.syncedHeight - entry.last_processed_height = 0 + entry.last_processed_height = w.syncedHeight entry.last_synced = w.lastSynced // Persisted unspent UTXOs for this wallet. The SPV inbound @@ -2267,15 +2283,23 @@ public class PlatformWalletPersistenceHandler { var row = IdentityKeyRestoreFFI() row.key_id = UInt32(bitPattern: pk.keyId) // PersistentPublicKey stores the discriminants as - // `String(rawValue)` of the original `UInt8` — same - // shape as the `purposeEnum` / `securityLevelEnum` / - // `keyTypeEnum` accessors on the model. Decode - // back to `UInt8`; fall back to 0 (the safest DPP - // default for each enum) on parse failure so we - // don't drop the row entirely. - row.key_type = UInt8(pk.keyType) ?? 0 - row.purpose = UInt8(pk.purpose) ?? 0 - row.security_level = UInt8(pk.securityLevel) ?? 0 + // `String(rawValue)` of the original `UInt8` — + // same shape as the `purposeEnum` / + // `securityLevelEnum` / `keyTypeEnum` accessors on + // the model. Decode back to `UInt8`; fall back to + // `UInt8.max` (an out-of-range sentinel) on parse + // failure so Rust's + // `KeyType::try_from(u8)` / + // `Purpose::try_from(u8)` / + // `SecurityLevel::try_from(u8)` rejects the row + // and `build_identity_public_keys` drops it. The + // prior fallback (`?? 0`) silently coerced + // corrupt rows into ECDSA_SECP256K1 / AUTHENTICATION + // / MASTER — a far worse outcome than a clean + // skip-and-continue. + row.key_type = UInt8(pk.keyType) ?? UInt8.max + row.purpose = UInt8(pk.purpose) ?? UInt8.max + row.security_level = UInt8(pk.securityLevel) ?? UInt8.max row.read_only = pk.readOnly // Allocate a dedicated byte buffer for the public From 4085463679b5023eb3ebd0e16ccc643337aa0a11 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 09:13:02 +0700 Subject: [PATCH 04/10] fix(swift-sdk): include legacy empty-walletId TXOs in restore The PersistentTxo.walletId column was added later and defaults to Data(); rows that haven't been touched by the inbound-tx path since the schema bump still carry an empty walletId (the persister has existing backfill logic at the inbound site). The previous fetch predicate (walletId == walletId) excluded those rows, so a freshly- restored wallet under-reported its UTXO/balance until SPV touched each affected TXO. Widen the predicate to include `walletId.isEmpty` rows in the same fetch, then filter Swift-side via the parent account's wallet relationship so a sibling wallet's legacy rows don't leak in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWalletPersistenceHandler.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 1a6a67c5a09..75a7fb189ef 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2159,15 +2159,35 @@ public class PlatformWalletPersistenceHandler { walletId: Data, allocation: LoadAllocation ) -> (UnsafeMutablePointer?, Int) { + // Pre-walletId-denorm rows still exist in older databases — + // `walletId` defaults to `Data()` and is filled in by the + // inbound-tx path on next touch (see the backfill at the + // `record.walletId.isEmpty, !resolvedWalletId.isEmpty` branch + // upstream in this file). Pull both buckets in one fetch and + // filter Swift-side so legacy rows route via their parent + // account's wallet relationship. let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId && $0.isSpent == false } + predicate: #Predicate { + $0.isSpent == false + && ($0.walletId == walletId || $0.walletId.isEmpty) + } ) guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { return (nil, 0) } - // Rows missing a parent `account` can't be routed Rust-side. - // Drop them rather than emit an unmappable row. - let routable = rows.filter { $0.account != nil } + // Rows missing a parent `account` can't be routed Rust-side + // (no tags). Empty-walletId rows additionally need to belong + // to *this* wallet via the account → wallet relationship — + // a concurrent legacy row from a sibling wallet would + // otherwise leak in. + let routable = rows.filter { row in + guard let account = row.account else { return false } + if row.walletId == walletId { return true } + if row.walletId.isEmpty { + return account.wallet.walletId == walletId + } + return false + } if routable.isEmpty { return (nil, 0) } From 5270cd5a141d459ffc8131c71209a4b2a696f939 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 10:28:43 +0700 Subject: [PATCH 05/10] fix(swift-sdk): address round-3+4 thepastaclaw review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking: - load_from_persistor: batch is now transactional. Tracks every successful insert into both wallet_manager and self.wallets in per-call vectors, breaks to a single rollback path on any iteration's failure, and unwinds every prior commit before returning Err. Previously a wallet-N failure left wallets 0..N-1 registered, poisoning every retry on WalletAlreadyExists. - persistence.rs platform-address restore: skip-and-warn on rows whose referenced account isn't in the reconstructed wallet (snapshot drift / not-yet-hydrated / stale cache). Also skips unsupported address_type values rather than aborting. Per-skip tracing::warn + a rollup line. Mirrors the UTXO restore path. Suggestions: - register_wallet: same orphan rollback pattern applied to the two ? returns after insert_wallet (load_persisted, initialize_from_persisted). - accessors.rs: account_balances_blocking now returns Vec instead of a positional 4-tuple. The FFI consumer reads named fields. Nitpicks: - diagnostic_sync_height_u32: u32::try_from(u64).unwrap_or(u32::MAX) saturates instead of silently wrapping; corrupt heights now surface visibly in the diagnostic panel. - platform_address_sync_config_blocking: dropped last_event_wallet_count (it aliased watch_list_size — both read wallets.len() — and the explorer rendered them as two independent observations). FFI struct, Swift wrapper, and explorer KVRow trimmed in lockstep. - loadWalletList: hoist the unspent-PersistentTxo fetch out of the per-wallet loop into a single bucketed pass keyed by walletId (legacy empty-walletId rows resolve via account.wallet.walletId). Prefetches the account/wallet relationship via relationshipKeyPathsForPrefetching so legacy-row routing doesn't trigger SwiftData faults per row. buildUtxoRestoreBuffer takes pre-bucketed rows and is now pure marshalling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/core_wallet_types.rs | 6 +- .../src/manager_diagnostics.rs | 1 - .../rs-platform-wallet-ffi/src/persistence.rs | 48 +++++++++-- packages/rs-platform-wallet-ffi/src/wallet.rs | 16 ++-- .../src/manager/accessors.rs | 52 +++++++---- .../rs-platform-wallet/src/manager/load.rs | 86 +++++++++++++------ .../src/manager/wallet_lifecycle.rs | 39 ++++++--- .../src/wallet/platform_addresses/provider.rs | 7 +- .../PlatformWalletManagerDiagnostics.swift | 3 - .../PlatformWalletPersistenceHandler.swift | 81 +++++++++-------- .../Views/WalletMemoryExplorerView.swift | 1 - 11 files changed, 221 insertions(+), 119 deletions(-) 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 127f67f845f..de00e3ac031 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -393,12 +393,14 @@ pub struct AccountBalanceEntryFFI { // iOS memory explorer. Each struct mirrors a `*Snapshot` type in // `platform-wallet`'s `manager::accessors` module 1:1. -/// Snapshot of [`PlatformAddressSyncManager`] configuration / counters. +/// Snapshot of [`PlatformAddressSyncManager`] configuration / last-pass +/// timestamp. `last_event_wallet_count` was dropped — it aliased +/// `watch_list_size` and rendering it as an independent field invited +/// confused interpretation. #[repr(C)] pub struct PlatformAddressSyncConfigFFI { pub interval_seconds: u64, pub watch_list_size: usize, - pub last_event_wallet_count: u32, pub last_event_unix_seconds: u64, } diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 85f741ada1c..ef3d6f47805 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -111,7 +111,6 @@ pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_config( *out_state = PlatformAddressSyncConfigFFI { interval_seconds: snap.interval_seconds, watch_list_size: snap.watch_list_size, - last_event_wallet_count: snap.last_event_wallet_count, last_event_unix_seconds: snap.last_event_unix_seconds, }; PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 53afe5ad345..c6a21e37dc0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1432,19 +1432,41 @@ fn build_wallet_start_state( ) } }; + let mut dropped_unknown_account = 0usize; + let mut dropped_unsupported_address_type = 0usize; for persisted in platform_balance_entries { if persisted.address.address_type != 0 { - return Err("only P2PKH platform address persistence is supported".into()); + // Non-P2PKH rows aren't supported on the persistence path + // yet. Skip rather than abort the whole load — the next + // platform-address sync will repopulate from authoritative + // state. + dropped_unsupported_address_type += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + address_type = persisted.address.address_type, + account_index = persisted.account_index, + "load: skipping persisted platform-address row with unsupported address_type" + ); + continue; } - let account_state = per_account - .get_mut(&persisted.account_index) - .ok_or_else(|| { - format!( - "persisted platform address references unknown account {}", - persisted.account_index - ) - })?; + // `per_account` is built only from the reconstructed wallet's + // platform-payment account map; the cached + // `platform_address_balances` slice can include rows whose + // referenced account didn't make it into the snapshot + // (deleted, not-yet-hydrated, stale cache). Skip-and-warn so + // a single drift row doesn't abort the whole `load()` — the + // sync coordinator will recompute on the next pass. + let Some(account_state) = per_account.get_mut(&persisted.account_index) else { + dropped_unknown_account += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + account_index = persisted.account_index, + address_index = persisted.address_index, + "load: skipping persisted platform-address row referencing unknown account" + ); + continue; + }; let p2pkh = key_wallet::PlatformP2PKHAddress::new(persisted.address.hash); account_state.insert_persisted_entry( persisted.address_index, @@ -1455,6 +1477,14 @@ fn build_wallet_start_state( }, ); } + if dropped_unknown_account > 0 || dropped_unsupported_address_type > 0 { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + dropped_unknown_account, + dropped_unsupported_address_type, + "load: persisted platform-address rows skipped during restore" + ); + } // Per-wallet identities go straight into the wallet_identities // sub-map keyed by registration index. Out-of-wallet identities diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 65b63d8b5da..2c9130a275c 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -151,8 +151,8 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( let entries: Vec = balances .into_iter() - .map(|(account_type, balance, keys_used, keys_total)| { - let tags = crate::core_wallet_types::account_type_to_tags(&account_type); + .map(|row| { + let tags = crate::core_wallet_types::account_type_to_tags(&row.account_type); crate::core_wallet_types::AccountBalanceEntryFFI { type_tag: tags.type_tag, standard_tag: tags.standard_tag, @@ -161,12 +161,12 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( 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(), - keys_used, - keys_total, + confirmed: row.balance.confirmed(), + unconfirmed: row.balance.unconfirmed(), + immature: row.balance.immature(), + locked: row.balance.locked(), + keys_used: row.keys_used, + keys_total: row.keys_total, } }) .collect(); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 1e8239c5e32..00600f66fa2 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -22,14 +22,32 @@ use super::PlatformWalletManager; /// Snapshot of [`PlatformAddressSyncManager`] tunables and last-event /// counters, returned from /// [`PlatformWalletManager::platform_address_sync_config_blocking`]. +/// +/// `last_event_wallet_count` was dropped — it aliased +/// `watch_list_size` (both read `wallets.len()`) and rendering it as +/// an independent observation in the explorer was misleading. If a +/// real per-event footprint metric ever lands on the sync manager, +/// add it back as a separate field sourced from there. #[derive(Debug, Clone, Copy)] pub struct PlatformAddressSyncConfigSnapshot { pub interval_seconds: u64, pub watch_list_size: usize, - pub last_event_wallet_count: u32, pub last_event_unix_seconds: u64, } +/// One row of the account-balance snapshot returned by +/// [`PlatformWalletManager::account_balances_blocking`]. Named fields +/// rather than a positional tuple so adding the next field +/// (`pool_count`, `last_used_height`, …) doesn't ripple through every +/// destructuring site. +#[derive(Debug, Clone, Copy)] +pub struct AccountBalanceRow { + pub account_type: AccountType, + pub balance: WalletCoreBalance, + pub keys_used: u32, + pub keys_total: u32, +} + /// Snapshot of [`IdentitySyncManager`] tunables / queue depth, returned /// from [`PlatformWalletManager::identity_sync_config_blocking`]. #[derive(Debug, Clone, Copy)] @@ -236,13 +254,14 @@ impl PlatformWalletManager

{ /// Read per-account balance + key-usage snapshots for a wallet. /// - /// Returns one tuple per managed account: the wallet's `AccountType`, - /// the live `WalletCoreBalance` (zero on keys-only variants by - /// construction), and (`keys_used`, `keys_total`) totals across the - /// account's address pools. Funds variants and keys variants both - /// expose pools the same way, so the count is meaningful in both - /// directions — the explorer surfaces it as the headline number on - /// keys-only rows where balance has no semantic content. + /// Returns one [`AccountBalanceSnapshot`] per managed account: the + /// wallet's `AccountType`, the live `WalletCoreBalance` (zero on + /// keys-only variants by construction), and (`keys_used`, + /// `keys_total`) totals across the account's address pools. + /// Funds variants and keys variants both expose pools the same + /// way, so the count is meaningful in both directions — the + /// explorer surfaces it as the headline number on keys-only rows + /// where balance has no semantic content. /// /// Uses `blocking_read` on the wallet manager lock; safe from /// non-async FFI context but must NOT be called from within a @@ -250,7 +269,7 @@ impl PlatformWalletManager

{ pub fn account_balances_blocking( &self, wallet_id: &WalletId, - ) -> Vec<(AccountType, WalletCoreBalance, u32, u32)> { + ) -> Vec { let wm = self.wallet_manager.blocking_read(); let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); @@ -283,12 +302,12 @@ impl PlatformWalletManager

{ let pool_total = pool.addresses.len() as u32; (used + pool_used, total + pool_total) }); - ( - account.managed_account_type().to_account_type(), + AccountBalanceRow { + account_type: account.managed_account_type().to_account_type(), balance, keys_used, keys_total, - ) + } }) .collect() } @@ -305,11 +324,9 @@ impl PlatformWalletManager

{ } /// Snapshot of [`PlatformAddressSyncManager`] tunables and last- - /// pass counters. The "watch list size" / "last event wallet - /// count" pair are the same value today (the sync manager doesn't - /// keep a separate watch list — every registered wallet is in the - /// pass), but the snapshot exposes both fields so the diagnostic - /// surface can grow if the two ever diverge. + /// pass timestamp. `watch_list_size` is `wallets.len()` — every + /// registered wallet participates in each pass since the sync + /// manager doesn't keep a separate watch list. pub fn platform_address_sync_config_blocking(&self) -> PlatformAddressSyncConfigSnapshot { let wallets = self.wallets.blocking_read(); let count = wallets.len(); @@ -322,7 +339,6 @@ impl PlatformWalletManager

{ PlatformAddressSyncConfigSnapshot { interval_seconds: interval.as_secs().max(1), watch_list_size: count, - last_event_wallet_count: count as u32, last_event_unix_seconds: last, } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index bae56759962..36ba66e89a8 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -7,7 +7,7 @@ use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletP use crate::error::PlatformWalletError; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; -use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; use super::PlatformWalletManager; @@ -42,7 +42,22 @@ impl PlatformWalletManager

{ let persister_dyn: Arc = Arc::clone(&self.persister) as _; - for (expected_wallet_id, wallet_state) in wallets { + // Track every wallet successfully inserted into + // `wallet_manager` and `self.wallets` during this call so the + // batch is transactional: if any later iteration fails (id + // mismatch, `initialize_from_persisted` error), we walk back + // every prior insert before bailing. Without this, a clean + // retry would collide on `WalletManager::insert_wallet` + // returning `WalletAlreadyExists` for every previously-loaded + // wallet — half-poisoning the manager until the process + // restarts. The orphan state is observable across the FFI + // boundary with no Swift-side reset path, so transactional + // semantics matter for this hydration API. + let mut inserted_in_manager: Vec = Vec::new(); + let mut inserted_in_wallets: Vec = Vec::new(); + let mut load_error: Option = None; + + 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { wallet, wallet_info, @@ -83,36 +98,31 @@ impl PlatformWalletManager

{ }; // Insert into `wallet_manager` first so we have a wallet - // handle to validate against, then either keep the - // registration or roll it back. The two failure modes - // below — recomputed id mismatch and platform-address - // restore — used to leave the wallet half-registered: - // present in `wallet_manager` but absent from - // `self.wallets`, which broke the manager's invariant - // that the two collections describe the same set and - // poisoned any retry path. + // handle to validate against. Track success in + // `inserted_in_manager` so the batch-rollback at the + // bottom can unwind on any later-iteration failure. let wallet_id = { let mut wm = self.wallet_manager.write().await; - wm.insert_wallet(wallet, platform_info).map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to register persisted wallet in WalletManager: {}", - e - )) - })? + match wm.insert_wallet(wallet, platform_info) { + Ok(id) => id, + Err(e) => { + load_error = Some(PlatformWalletError::WalletCreation(format!( + "Failed to register persisted wallet in WalletManager: {}", + e + ))); + break 'load; + } + } }; + inserted_in_manager.push(wallet_id); if wallet_id != expected_wallet_id { - // Roll back the insert before bailing — the wallet - // we just registered isn't the one the snapshot - // claimed it was, and leaving it in `wallet_manager` - // would collide on the next retry. - let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(&wallet_id); - return Err(PlatformWalletError::WalletCreation(format!( + load_error = Some(PlatformWalletError::WalletCreation(format!( "Persisted wallet id {} does not match recomputed id {}", hex::encode(expected_wallet_id), hex::encode(wallet_id) ))); + break 'load; } let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( @@ -131,20 +141,18 @@ impl PlatformWalletManager

{ // Initialize the platform-address provider. If the snapshot // carried a slice for this wallet, restore it directly; // otherwise do a fresh scan from the live wallet manager. - // Roll back the `insert_wallet` on failure so the caller - // can retry without stepping over a stale registration. + // Failures break to the rollback path below. if let Some(persisted) = platform_addresses.remove(&wallet_id) { if let Err(e) = platform_wallet .platform() .initialize_from_persisted(persisted) .await { - let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(&wallet_id); - return Err(PlatformWalletError::WalletCreation(format!( + load_error = Some(PlatformWalletError::WalletCreation(format!( "Failed to restore platform address state: {}", e ))); + break 'load; } } else { platform_wallet.platform().initialize().await; @@ -153,6 +161,28 @@ impl PlatformWalletManager

{ let platform_wallet = Arc::new(platform_wallet); let mut wallets_guard = self.wallets.write().await; wallets_guard.insert(wallet_id, platform_wallet); + drop(wallets_guard); + inserted_in_wallets.push(wallet_id); + } + + if let Some(err) = load_error { + // Walk back every wallet committed in this call so the + // manager state matches what it was before. Order: + // remove from `self.wallets` first (UI surface), then + // from the inner `wallet_manager`. + if !inserted_in_wallets.is_empty() { + let mut wallets_guard = self.wallets.write().await; + for id in &inserted_in_wallets { + wallets_guard.remove(id); + } + } + if !inserted_in_manager.is_empty() { + let mut wm = self.wallet_manager.write().await; + for id in &inserted_in_manager { + let _ = wm.remove_wallet(id); + } + } + return Err(err); } Ok(()) diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 826d5e8f3b4..1042feb440a 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -268,27 +268,40 @@ impl PlatformWalletManager

{ // `AddressPool` scan `initialize` would otherwise do. // Per-wallet UTXOs / unused asset locks ship in the snapshot // but don't have an active restore path yet. + // + // The two `?` returns below would otherwise leave the wallet + // half-registered (present in `wallet_manager` from the + // earlier `insert_wallet`, absent from `self.wallets`), + // poisoning every retry on `WalletAlreadyExists`. Roll back + // before bailing — same shape as `manager::load`. let crate::changeset::ClientStartState { mut platform_addresses, wallets: _, - } = platform_wallet.load_persisted().map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to load persisted wallet state: {}", - e - )) - })?; + } = match platform_wallet.load_persisted() { + Ok(state) => state, + Err(e) => { + let mut wm = self.wallet_manager.write().await; + let _ = wm.remove_wallet(&wallet_id); + return Err(PlatformWalletError::WalletCreation(format!( + "Failed to load persisted wallet state: {}", + e + ))); + } + }; if let Some(persisted) = platform_addresses.remove(&wallet_id) { - platform_wallet + if let Err(e) = platform_wallet .platform() .initialize_from_persisted(persisted) .await - .map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to restore persisted platform address state: {}", - e - )) - })?; + { + let mut wm = self.wallet_manager.write().await; + let _ = wm.remove_wallet(&wallet_id); + return Err(PlatformWalletError::WalletCreation(format!( + "Failed to restore persisted platform address state: {}", + e + ))); + } } else { platform_wallet.platform().initialize().await; } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index 52311e24356..d5836be9ff1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -463,9 +463,12 @@ impl PlatformPaymentAddressProvider { /// Diagnostic getter — the unified-pass watermark height as a /// `u32` (the SDK exposes it as `u64` internally; the diagnostic /// surface is `u32` to match the rest of the explorer's height - /// fields). + /// fields). Saturates at `u32::MAX` rather than silently wrapping + /// — Dash core heights never reach that range in practice, so + /// any value that would truncate is corruption / a sentinel that + /// should surface visibly in the diagnostic panel. pub fn diagnostic_sync_height_u32(&self) -> u32 { - self.sync_height as u32 + u32::try_from(self.sync_height).unwrap_or(u32::MAX) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift index 2616915d0b1..586397a3fcd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDiagnostics.swift @@ -30,7 +30,6 @@ extension PlatformWalletManager { public struct PlatformAddressSyncConfigSnapshot { public let intervalSeconds: UInt64 public let watchListSize: Int - public let lastEventWalletCount: UInt32 public let lastEventUnixSeconds: UInt64 } @@ -39,7 +38,6 @@ extension PlatformWalletManager { var out = PlatformAddressSyncConfigFFI( interval_seconds: 0, watch_list_size: 0, - last_event_wallet_count: 0, last_event_unix_seconds: 0 ) let res = platform_wallet_manager_platform_address_sync_config(handle, &out) @@ -47,7 +45,6 @@ extension PlatformWalletManager { return PlatformAddressSyncConfigSnapshot( intervalSeconds: out.interval_seconds, watchListSize: Int(out.watch_list_size), - lastEventWalletCount: out.last_event_wallet_count, lastEventUnixSeconds: out.last_event_unix_seconds ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 75a7fb189ef..cdc10f0d296 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1967,6 +1967,42 @@ public class PlatformWalletPersistenceHandler { allocation.entries = entriesPtr allocation.entriesCount = restorable.count + // Single bucketed fetch of every unspent `PersistentTxo` so + // each wallet's per-iteration buffer build is a dictionary + // lookup instead of a fresh database round-trip. Prefetches + // `account.wallet` to keep the legacy-walletId routing path + // (rows whose `walletId` field defaults to `Data()` because + // they predate the denorm) from triggering one SwiftData + // fault per row when we resolve the parent wallet. + var unspentBuckets: [Data: [PersistentTxo]] = [:] + do { + var unspentDescriptor = FetchDescriptor( + predicate: #Predicate { $0.isSpent == false } + ) + unspentDescriptor.relationshipKeyPathsForPrefetching = [\.account, \.account?.wallet] + if let unspent = try? backgroundContext.fetch(unspentDescriptor) { + unspentBuckets.reserveCapacity(restorable.count) + for row in unspent { + guard row.account != nil else { continue } + let key: Data + if !row.walletId.isEmpty { + key = row.walletId + } else if let account = row.account { + // `account.wallet` is non-optional on the + // model but is a fault-loaded relationship; + // a relationship-store inconsistency would + // crash here, so guard via Optional cast. + let wallet: PersistentWallet? = account.wallet + guard let resolved = wallet else { continue } + key = resolved.walletId + } else { + continue + } + unspentBuckets[key, default: []].append(row) + } + } + } + for (i, w) in restorable.enumerated() { let sortedAccounts = w.accounts .filter { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } @@ -2105,7 +2141,7 @@ public class PlatformWalletPersistenceHandler { // account isn't a funds variant get silently skipped on // the receiving side. let (utxoBuf, utxoCount) = buildUtxoRestoreBuffer( - walletId: w.walletId, + rows: unspentBuckets[w.walletId] ?? [], allocation: allocation ) entry.utxos = utxoBuf.map { UnsafePointer($0) } @@ -2155,45 +2191,22 @@ public class PlatformWalletPersistenceHandler { /// Rows whose `outpoint` payload isn't 32 bytes are skipped — the /// model stores it as `Data` (`outpoint: Data`) and bad data /// shouldn't crash the FFI handoff. + /// Build the per-wallet UTXO restore buffer from a list of + /// `PersistentTxo` rows already bucketed for this wallet by the + /// caller. The bucketing pass in `loadWalletList` does the + /// SwiftData fetch once for the whole batch (legacy empty-walletId + /// rows route via `account.wallet.walletId`), so this function is + /// pure marshalling. private func buildUtxoRestoreBuffer( - walletId: Data, + rows: [PersistentTxo], allocation: LoadAllocation ) -> (UnsafeMutablePointer?, Int) { - // Pre-walletId-denorm rows still exist in older databases — - // `walletId` defaults to `Data()` and is filled in by the - // inbound-tx path on next touch (see the backfill at the - // `record.walletId.isEmpty, !resolvedWalletId.isEmpty` branch - // upstream in this file). Pull both buckets in one fetch and - // filter Swift-side so legacy rows route via their parent - // account's wallet relationship. - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.isSpent == false - && ($0.walletId == walletId || $0.walletId.isEmpty) - } - ) - guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { - return (nil, 0) - } - // Rows missing a parent `account` can't be routed Rust-side - // (no tags). Empty-walletId rows additionally need to belong - // to *this* wallet via the account → wallet relationship — - // a concurrent legacy row from a sibling wallet would - // otherwise leak in. - let routable = rows.filter { row in - guard let account = row.account else { return false } - if row.walletId == walletId { return true } - if row.walletId.isEmpty { - return account.wallet.walletId == walletId - } - return false - } - if routable.isEmpty { + if rows.isEmpty { return (nil, 0) } - let buf = UnsafeMutablePointer.allocate(capacity: routable.count) + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) var written = 0 - for record in routable { + for record in rows { guard let account = record.account else { continue } // `outpoint` on `PersistentTxo` is 36 bytes (32-byte txid // followed by LE u32 vout) — composed via diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift index 2d5684ab302..13da19302fd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift @@ -287,7 +287,6 @@ struct WalletMemoryExplorerView: View { if let cfg = addressSyncConfig { KVRow(label: "Interval (s)", value: "\(cfg.intervalSeconds)") KVRow(label: "Watch List Size", value: "\(cfg.watchListSize)") - KVRow(label: "Last Event Wallets", value: "\(cfg.lastEventWalletCount)") KVRow( label: "Last Event", value: formatTimestamp(cfg.lastEventUnixSeconds) From 91af15fb12fba89999dda246b05915fa2a5f20f4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 11:29:31 +0700 Subject: [PATCH 06/10] fix(swift-sdk): address round-5 thepastaclaw blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two FFI / restore-path soundness issues: 1. AccountSpecFFI / UtxoRestoreEntryFFI: type_tag and standard_tag are now plain `u8` on the FFI surface (were `repr(u8)` enum fields). Reading a foreign byte directly into a `repr(u8)` enum slot is UB for out-of-range values, *before* the match runs — corrupt SwiftData rows or forward-versioned tags would have been undefined behaviour rather than a recoverable validation error. New `AccountTypeTagFFI::try_from_u8` and `StandardAccountTypeTagFFI::try_from_u8` validate the byte; account_type_from_spec (in both persistence.rs and the manager_diagnostics duplicate) calls them up front and returns PersistenceError::Backend on out-of-range bytes. Producer sites cast as `u8`. Swift side already wrote the fields as plain bytes, so no Swift change required. 2. loadWalletList: SwiftData fetch failures (PersistentWallet, unspent PersistentTxo) now propagate to Rust via a new `errored: Bool` tuple element. The callback returns 1 on errored, so the Rust loader aborts instead of silently degrading to an empty restore (which previously would have surfaced as "successful 0-balance restore" — exactly the failure mode the UTXO-load path was added to eliminate). Each fetch failure is NSLog'd with the underlying error for debugability. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/manager_diagnostics.rs | 23 +++++- .../rs-platform-wallet-ffi/src/persistence.rs | 67 ++++++++++----- .../src/wallet_restore_types.rs | 66 +++++++++++++-- .../PlatformWalletPersistenceHandler.swift | 82 +++++++++++++------ 4 files changed, 181 insertions(+), 57 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index ef3d6f47805..b8986ae7dbd 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -812,9 +812,26 @@ fn account_type_from_spec_ref( ) -> Result { use crate::wallet_restore_types::{AccountTypeTagFFI, StandardAccountTypeTagFFI}; use key_wallet::account::{AccountType, StandardAccountType}; - Ok(match spec.type_tag { + // `spec.type_tag` is now a plain `u8` on the FFI surface — validate + // before matching so an out-of-range byte from a corrupt SwiftData + // row / forward-versioned tag doesn't trigger UB on a `repr(u8)` + // enum match. Same shape as `persistence::account_type_from_spec`. + let type_tag = AccountTypeTagFFI::try_from_u8(spec.type_tag).ok_or_else(|| { + format!( + "AccountSpecFFI carries unknown type_tag byte {} (out of declared range)", + spec.type_tag + ) + })?; + Ok(match type_tag { AccountTypeTagFFI::Standard => { - let standard_account_type = match spec.standard_tag { + let standard_tag = + StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag).ok_or_else(|| { + format!( + "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", + spec.standard_tag + ) + })?; + let standard_account_type = match standard_tag { StandardAccountTypeTagFFI::Bip44 => StandardAccountType::BIP44Account, StandardAccountTypeTagFFI::Bip32 => StandardAccountType::BIP32Account, }; @@ -858,7 +875,7 @@ fn account_type_from_spec_ref( | AccountTypeTagFFI::IdentityAuthenticationBls => { return Err(format!( "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType", - spec.type_tag + type_tag )); } }) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index c6a21e37dc0..9095c6b2514 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -869,8 +869,8 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco // variants stay at their zero value and are ignored on the // receiving side per the struct docs. let mut spec = AccountSpecFFI { - type_tag: AccountTypeTagFFI::Standard, - standard_tag: StandardAccountTypeTagFFI::Bip44, + type_tag: AccountTypeTagFFI::Standard as u8, + standard_tag: StandardAccountTypeTagFFI::Bip44 as u8, index: 0, registration_index: 0, key_class: 0, @@ -879,59 +879,64 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco account_xpub_bytes: xpub_bytes.as_ptr(), account_xpub_bytes_len: xpub_bytes.len(), }; + // The producer side casts each `AccountTypeTagFFI` / + // `StandardAccountTypeTagFFI` variant to `u8` because both fields + // are now FFI-typed as plain `u8` (see the field comments on + // `AccountSpecFFI`). The consumer validates the byte via + // `try_from_u8` before any `match`. match account_type { AccountType::Standard { index, standard_account_type, } => { - spec.type_tag = AccountTypeTagFFI::Standard; + spec.type_tag = AccountTypeTagFFI::Standard as u8; spec.standard_tag = match standard_account_type { - StandardAccountType::BIP44Account => StandardAccountTypeTagFFI::Bip44, - StandardAccountType::BIP32Account => StandardAccountTypeTagFFI::Bip32, + StandardAccountType::BIP44Account => StandardAccountTypeTagFFI::Bip44 as u8, + StandardAccountType::BIP32Account => StandardAccountTypeTagFFI::Bip32 as u8, }; spec.index = *index; } AccountType::CoinJoin { index } => { - spec.type_tag = AccountTypeTagFFI::CoinJoin; + spec.type_tag = AccountTypeTagFFI::CoinJoin as u8; spec.index = *index; } AccountType::IdentityRegistration => { - spec.type_tag = AccountTypeTagFFI::IdentityRegistration; + spec.type_tag = AccountTypeTagFFI::IdentityRegistration as u8; } AccountType::IdentityTopUp { registration_index } => { - spec.type_tag = AccountTypeTagFFI::IdentityTopUp; + spec.type_tag = AccountTypeTagFFI::IdentityTopUp as u8; spec.registration_index = *registration_index; } AccountType::IdentityTopUpNotBoundToIdentity => { - spec.type_tag = AccountTypeTagFFI::IdentityTopUpNotBoundToIdentity; + spec.type_tag = AccountTypeTagFFI::IdentityTopUpNotBoundToIdentity as u8; } AccountType::IdentityInvitation => { - spec.type_tag = AccountTypeTagFFI::IdentityInvitation; + spec.type_tag = AccountTypeTagFFI::IdentityInvitation as u8; } AccountType::AssetLockAddressTopUp => { - spec.type_tag = AccountTypeTagFFI::AssetLockAddressTopUp; + spec.type_tag = AccountTypeTagFFI::AssetLockAddressTopUp as u8; } AccountType::AssetLockShieldedAddressTopUp => { - spec.type_tag = AccountTypeTagFFI::AssetLockShieldedAddressTopUp; + spec.type_tag = AccountTypeTagFFI::AssetLockShieldedAddressTopUp as u8; } AccountType::ProviderVotingKeys => { - spec.type_tag = AccountTypeTagFFI::ProviderVotingKeys; + spec.type_tag = AccountTypeTagFFI::ProviderVotingKeys as u8; } AccountType::ProviderOwnerKeys => { - spec.type_tag = AccountTypeTagFFI::ProviderOwnerKeys; + spec.type_tag = AccountTypeTagFFI::ProviderOwnerKeys as u8; } AccountType::ProviderOperatorKeys => { - spec.type_tag = AccountTypeTagFFI::ProviderOperatorKeys; + spec.type_tag = AccountTypeTagFFI::ProviderOperatorKeys as u8; } AccountType::ProviderPlatformKeys => { - spec.type_tag = AccountTypeTagFFI::ProviderPlatformKeys; + spec.type_tag = AccountTypeTagFFI::ProviderPlatformKeys as u8; } AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id, } => { - spec.type_tag = AccountTypeTagFFI::DashpayReceivingFunds; + spec.type_tag = AccountTypeTagFFI::DashpayReceivingFunds as u8; spec.index = *index; spec.user_identity_id = *user_identity_id; spec.friend_identity_id = *friend_identity_id; @@ -941,13 +946,13 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco user_identity_id, friend_identity_id, } => { - spec.type_tag = AccountTypeTagFFI::DashpayExternalAccount; + spec.type_tag = AccountTypeTagFFI::DashpayExternalAccount as u8; spec.index = *index; spec.user_identity_id = *user_identity_id; spec.friend_identity_id = *friend_identity_id; } AccountType::PlatformPayment { account, key_class } => { - spec.type_tag = AccountTypeTagFFI::PlatformPayment; + spec.type_tag = AccountTypeTagFFI::PlatformPayment as u8; spec.index = *account; spec.key_class = *key_class; } // TODO(events): the `IdentityAuthenticationEcdsa` / @@ -1698,9 +1703,27 @@ fn identity_status_from_tag(tag: u8) -> IdentityStatus { } fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - Ok(match spec.type_tag { + // Validate the foreign byte before matching — `spec.type_tag` and + // `spec.standard_tag` are now plain `u8` on the FFI surface + // (previously typed as `repr(u8)` enum fields, which would have + // been UB for out-of-range bytes from a corrupt SwiftData row / + // forward-versioned tag / malformed host buffer). + let type_tag = AccountTypeTagFFI::try_from_u8(spec.type_tag).ok_or_else(|| { + PersistenceError::Backend(format!( + "AccountSpecFFI carries unknown type_tag byte {} (out of declared range)", + spec.type_tag + )) + })?; + Ok(match type_tag { AccountTypeTagFFI::Standard => { - let standard_account_type = match spec.standard_tag { + let standard_tag = + StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag).ok_or_else(|| { + PersistenceError::Backend(format!( + "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", + spec.standard_tag + )) + })?; + let standard_account_type = match standard_tag { StandardAccountTypeTagFFI::Bip44 => StandardAccountType::BIP44Account, StandardAccountTypeTagFFI::Bip32 => StandardAccountType::BIP32Account, }; @@ -1752,7 +1775,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { return Err(PersistenceError::Backend(format!( "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))", - spec.type_tag + type_tag ))); } }) diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c22288e0bdc..cc82db583b3 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -27,7 +27,11 @@ use crate::types::FFINetwork; /// Discriminant for [`key_wallet::account::AccountType`]. /// /// Keep the integer values stable across releases — they end up in -/// SwiftData rows on the client. +/// SwiftData rows on the client. Carried across the FFI boundary as +/// a plain `u8` (see `AccountSpecFFI.type_tag`); validated via +/// [`AccountTypeTagFFI::try_from_u8`] before any `match`. Reading a +/// foreign `u8` directly into a `repr(u8)` enum field would be UB +/// for out-of-range values *before* the match runs. #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountTypeTagFFI { @@ -56,8 +60,39 @@ pub enum AccountTypeTagFFI { IdentityAuthenticationBls = 16, } +impl AccountTypeTagFFI { + /// Validating constructor for an FFI byte. Out-of-range bytes + /// (corrupt SwiftData row, forward-versioned tag, malformed + /// host buffer) return `None` so callers surface a recoverable + /// validation error rather than triggering UB on an enum match. + pub fn try_from_u8(b: u8) -> Option { + Some(match b { + 0 => Self::Standard, + 1 => Self::CoinJoin, + 2 => Self::IdentityRegistration, + 3 => Self::IdentityTopUp, + 4 => Self::IdentityTopUpNotBoundToIdentity, + 5 => Self::IdentityInvitation, + 6 => Self::AssetLockAddressTopUp, + 7 => Self::AssetLockShieldedAddressTopUp, + 8 => Self::ProviderVotingKeys, + 9 => Self::ProviderOwnerKeys, + 10 => Self::ProviderOperatorKeys, + 11 => Self::ProviderPlatformKeys, + 12 => Self::DashpayReceivingFunds, + 13 => Self::DashpayExternalAccount, + 14 => Self::PlatformPayment, + 15 => Self::IdentityAuthenticationEcdsa, + 16 => Self::IdentityAuthenticationBls, + _ => return None, + }) + } +} + /// Discriminant for [`key_wallet::account::StandardAccountType`]. -/// Only meaningful when `AccountSpecFFI.type_tag == AccountTypeTagFFI::Standard`. +/// Only meaningful when the parent `type_tag` is +/// [`AccountTypeTagFFI::Standard`]. Same FFI-`u8`-with-validating-ctor +/// shape as `AccountTypeTagFFI` for the same UB-avoidance reason. #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StandardAccountTypeTagFFI { @@ -65,6 +100,16 @@ pub enum StandardAccountTypeTagFFI { Bip32 = 1, } +impl StandardAccountTypeTagFFI { + pub fn try_from_u8(b: u8) -> Option { + Some(match b { + 0 => Self::Bip44, + 1 => Self::Bip32, + _ => return None, + }) + } +} + /// Flat account spec carried in `WalletRestoreEntryFFI.accounts`. /// /// Field relevance per `type_tag`: @@ -87,8 +132,14 @@ pub enum StandardAccountTypeTagFFI { /// * `IdentityAuthenticationBls` — `index` (as `identity_index`) #[repr(C)] pub struct AccountSpecFFI { - pub type_tag: AccountTypeTagFFI, - pub standard_tag: StandardAccountTypeTagFFI, + /// Raw byte projection of [`AccountTypeTagFFI`]. Validated via + /// [`AccountTypeTagFFI::try_from_u8`] on the Rust side before any + /// `match` — reading a foreign byte directly into a `repr(u8)` + /// enum field would be UB for out-of-range values. + pub type_tag: u8, + /// Raw byte projection of [`StandardAccountTypeTagFFI`]. Same + /// validation pattern as `type_tag`. + pub standard_tag: u8, pub index: u32, pub registration_index: u32, pub key_class: u32, @@ -230,8 +281,11 @@ pub struct IdentityRestoreEntryFFI { /// no C-string field is needed here. #[repr(C)] pub struct UtxoRestoreEntryFFI { - pub type_tag: AccountTypeTagFFI, - pub standard_tag: StandardAccountTypeTagFFI, + /// Raw byte projection of [`AccountTypeTagFFI`]. Validated via + /// [`AccountTypeTagFFI::try_from_u8`] on the Rust side. See + /// `AccountSpecFFI.type_tag` for the UB-avoidance rationale. + pub type_tag: u8, + pub standard_tag: u8, pub account_index: u32, pub registration_index: u32, pub key_class: u32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index cdc10f0d296..a7289e5febb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1947,17 +1947,29 @@ public class PlatformWalletPersistenceHandler { /// array, wallet id from the top-level struct. /// /// Returns `(nil, 0)` if nothing is restorable. - func loadWalletList() -> (entries: UnsafePointer?, count: Int) { + func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { onQueue { let walletDescriptor = FetchDescriptor() - guard let wallets = try? backgroundContext.fetch(walletDescriptor) else { - return (nil, 0) + let wallets: [PersistentWallet] + do { + wallets = try backgroundContext.fetch(walletDescriptor) + } catch { + // Surfacing the SwiftData failure to Rust is critical — + // returning success-with-empty here would let restore + // appear to "succeed" with zero wallets, hiding a real + // database fault from the user. The callback returns + // non-zero on `errored == true`. + NSLog( + "[persistor-load:swift] PersistentWallet fetch failed: %@", + String(describing: error) + ) + return (nil, 0, true) } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } if restorable.isEmpty { - return (nil, 0) + return (nil, 0, false) } let allocation = LoadAllocation() @@ -1980,26 +1992,40 @@ public class PlatformWalletPersistenceHandler { predicate: #Predicate { $0.isSpent == false } ) unspentDescriptor.relationshipKeyPathsForPrefetching = [\.account, \.account?.wallet] - if let unspent = try? backgroundContext.fetch(unspentDescriptor) { - unspentBuckets.reserveCapacity(restorable.count) - for row in unspent { - guard row.account != nil else { continue } - let key: Data - if !row.walletId.isEmpty { - key = row.walletId - } else if let account = row.account { - // `account.wallet` is non-optional on the - // model but is a fault-loaded relationship; - // a relationship-store inconsistency would - // crash here, so guard via Optional cast. - let wallet: PersistentWallet? = account.wallet - guard let resolved = wallet else { continue } - key = resolved.walletId - } else { - continue - } - unspentBuckets[key, default: []].append(row) + // Bail with `errored = true` on a SwiftData failure rather + // than degrading to an empty bucket map. Without this, Rust + // would see `entry.utxos_count == 0` for every wallet, + // skip `wallet_info.update_balance()`, and the restore + // would silently report zero core-chain funds — exactly + // the failure mode this code path was added to eliminate. + let unspent: [PersistentTxo] + do { + unspent = try backgroundContext.fetch(unspentDescriptor) + } catch { + NSLog( + "[persistor-load:swift] PersistentTxo unspent fetch failed: %@", + String(describing: error) + ) + return (nil, 0, true) + } + unspentBuckets.reserveCapacity(restorable.count) + for row in unspent { + guard row.account != nil else { continue } + let key: Data + if !row.walletId.isEmpty { + key = row.walletId + } else if let account = row.account { + // `account.wallet` is non-optional on the + // model but is a fault-loaded relationship; + // a relationship-store inconsistency would + // crash here, so guard via Optional cast. + let wallet: PersistentWallet? = account.wallet + guard let resolved = wallet else { continue } + key = resolved.walletId + } else { + continue } + unspentBuckets[key, default: []].append(row) } } @@ -2155,7 +2181,7 @@ public class PlatformWalletPersistenceHandler { let typed = UnsafePointer(entriesPtr) loadAllocations[UnsafeRawPointer(typed)] = allocation - return (typed, restorable.count) + return (typed, restorable.count, false) } // onQueue } @@ -2729,10 +2755,14 @@ private func loadWalletListCallback( let handler = Unmanaged .fromOpaque(context) .takeUnretainedValue() - let (entries, count) = handler.loadWalletList() + let (entries, count, errored) = handler.loadWalletList() outEntries.pointee = entries outCount.pointee = UInt(count) - return 0 + // Surface SwiftData fetch failures as a non-zero callback return so + // the Rust loader aborts instead of silently degrading to an empty + // restore (which previously masked database faults as + // "successful 0-balance restore"). + return errored ? 1 : 0 } private func loadWalletListFreeCallback( From 6472d011b6393c863964d3ccd9160a168562d737 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 11:44:22 +0700 Subject: [PATCH 07/10] fix(swift-sdk): address round-6 coderabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. persistence.rs: distinguish "legacy IdentityAuthentication{Ecdsa,Bls} tag" (skip-and-continue) from "out-of-range type_tag byte" (propagate Err). The previous `let Ok(...) else continue` swallowed *all* PersistenceErrors from account_type_from_spec, including the new try_from_u8 validation failures, hiding corruption as silent under-restoration. Both the account loader and the UTXO loop now match on the result and only swallow the legacy tag bytes (15/16); everything else propagates. 2. PlatformWalletPersistenceHandler: move the LoadAllocation + entriesPtr allocation to AFTER the unspent-PersistentTxo fetch so an early fetch failure (return errored=true) doesn't leak the entries buffer. 3. PlatformWalletPersistenceHandler: replace `UInt8(truncatingIfNeeded: acc.accountType)` with `UInt8(exactly:)` + skip-on-overflow at both the account and UTXO spec construction sites. Truncation would have silently wrapped corrupt 0x100+ accountType values into the 0–255 range, defeating Rust's try_from_u8 validation. The account-spec writer also gained a `written` counter (same compaction pattern as the AddressBalanceEntryFFI / UtxoRestoreEntryFFI writers) so a skipped row doesn't leave an uninitialized FFI slot in the published slice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet-ffi/src/persistence.rs | 63 ++++++++++----- .../PlatformWalletPersistenceHandler.swift | 80 ++++++++++++++----- 2 files changed, 106 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 9095c6b2514..6de736f4414 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1180,13 +1180,27 @@ fn build_wallet_start_state( // such row exists in SwiftData. Treating it as recoverable // snapshot drift matches how the UTXO loop a few lines below // handles the same failure mode. - let Ok(account_type) = account_type_from_spec(spec) else { - tracing::warn!( - wallet_id = %hex::encode(entry.wallet_id), - type_tag = ?spec.type_tag, - "load: skipping persisted account row with unmappable AccountType" - ); - continue; + // + // Only the *legacy* tag bytes (15 / 16) are skip-and-continue; + // real validation errors (out-of-range bytes from a corrupt + // SwiftData row) propagate so the corruption surfaces rather + // than silently under-restoring accounts. + let account_type = match account_type_from_spec(spec) { + Ok(t) => t, + Err(e) => { + let is_legacy_tag = spec.type_tag + == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 + || spec.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; + if is_legacy_tag { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + type_tag = spec.type_tag, + "load: skipping legacy IdentityAuthentication account tag" + ); + continue; + } + return Err(e); + } }; let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; @@ -1268,18 +1282,29 @@ fn build_wallet_start_state( account_xpub_bytes: std::ptr::null(), account_xpub_bytes_len: 0, }; - // Tags that don't map to any current `AccountType` (e.g. - // legacy `IdentityAuthentication{Ecdsa,Bls}`) are skipped — - // the SwiftData row can't be restored cleanly and the next - // sync will recover any funds it represents. - let Ok(account_type) = account_type_from_spec(&spec) else { - dropped_account_type += 1; - tracing::warn!( - wallet_id = %hex::encode(entry.wallet_id), - type_tag = ?u.type_tag, - "load: skipping persisted UTXO with unmappable AccountType" - ); - continue; + // Skip-and-continue is correct ONLY for the legacy + // `IdentityAuthentication{Ecdsa,Bls}` tag bytes (15 / 16) + // whose upstream `AccountType` variants were removed. Real + // validation errors (out-of-range bytes from a corrupt + // SwiftData row, etc.) propagate so the corruption surfaces + // rather than silently under-restoring the UTXO set. + let account_type = match account_type_from_spec(&spec) { + Ok(t) => t, + Err(e) => { + let is_legacy_tag = u.type_tag + == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 + || u.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; + if is_legacy_tag { + dropped_account_type += 1; + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + type_tag = u.type_tag, + "load: skipping persisted UTXO on legacy IdentityAuthentication tag" + ); + continue; + } + return Err(e); + } }; let Ok(txid) = dashcore::Txid::from_slice(&u.prev_txid) else { dropped_bad_txid += 1; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a7289e5febb..9db6a1d6572 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1972,13 +1972,6 @@ public class PlatformWalletPersistenceHandler { return (nil, 0, false) } - let allocation = LoadAllocation() - let entriesPtr = UnsafeMutablePointer.allocate( - capacity: restorable.count - ) - allocation.entries = entriesPtr - allocation.entriesCount = restorable.count - // Single bucketed fetch of every unspent `PersistentTxo` so // each wallet's per-iteration buffer build is a dictionary // lookup instead of a fresh database round-trip. Prefetches @@ -1986,6 +1979,12 @@ public class PlatformWalletPersistenceHandler { // (rows whose `walletId` field defaults to `Data()` because // they predate the denorm) from triggering one SwiftData // fault per row when we resolve the parent wallet. + // + // The fetch happens BEFORE we allocate `entriesPtr` / + // `LoadAllocation` so an early fetch failure doesn't leak + // the entries buffer (`LoadAllocation.release` is only + // called on the path through `loadAllocations` after the + // pointer hand-off to Rust succeeds). var unspentBuckets: [Data: [PersistentTxo]] = [:] do { var unspentDescriptor = FetchDescriptor( @@ -2029,6 +2028,18 @@ public class PlatformWalletPersistenceHandler { } } + // Allocate `entriesPtr` and the `LoadAllocation` here — past + // the fallible SwiftData fetch above — so an early-error path + // doesn't leak the entries buffer (LoadAllocation only gets + // released through the `loadAllocations` map after the + // successful pointer hand-off at the bottom of this fn). + let allocation = LoadAllocation() + let entriesPtr = UnsafeMutablePointer.allocate( + capacity: restorable.count + ) + allocation.entries = entriesPtr + allocation.entriesCount = restorable.count + for (i, w) in restorable.enumerated() { let sortedAccounts = w.accounts .filter { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } @@ -2037,19 +2048,34 @@ public class PlatformWalletPersistenceHandler { < ($1.accountType, $1.accountIndex, $1.registrationIndex, $1.keyClass) } let accountsBuffer: UnsafeMutablePointer? + let accountsWritten: Int if sortedAccounts.isEmpty { accountsBuffer = nil + accountsWritten = 0 } else { let buf = UnsafeMutablePointer.allocate(capacity: sortedAccounts.count) - for (j, acc) in sortedAccounts.enumerated() { + var written = 0 + for acc in sortedAccounts { // Filter above guarantees non-nil + non-empty. let xpub = acc.accountExtendedPubKeyBytes ?? Data() + // Reject rows whose `accountType` (UInt32) doesn't + // fit in `u8`. `truncatingIfNeeded` would silently + // wrap a corrupt 0x100+ value into a potentially- + // valid tag in the 0–255 range, defeating Rust's + // `AccountTypeTagFFI::try_from_u8` validation. + guard let typeTagByte = UInt8(exactly: acc.accountType) else { + NSLog( + "[persistor-load:swift] skipping account row: accountType %u out of UInt8 range", + acc.accountType + ) + continue + } let xpubBuffer = UnsafeMutablePointer.allocate(capacity: xpub.count) xpub.copyBytes(to: xpubBuffer, count: xpub.count) allocation.scalarBuffers.append((xpubBuffer, xpub.count)) var spec = AccountSpecFFI() - spec.type_tag = UInt8(truncatingIfNeeded: acc.accountType) + spec.type_tag = typeTagByte spec.standard_tag = acc.standardTag spec.index = acc.accountIndex spec.registration_index = acc.registrationIndex @@ -2058,10 +2084,18 @@ public class PlatformWalletPersistenceHandler { copyBytes(acc.friendIdentityId, into: &spec.friend_identity_id) spec.account_xpub_bytes = UnsafePointer(xpubBuffer) spec.account_xpub_bytes_len = UInt(xpub.count) - buf[j] = spec + buf[written] = spec + written += 1 + } + if written == 0 { + buf.deallocate() + accountsBuffer = nil + accountsWritten = 0 + } else { + accountsBuffer = buf + accountsWritten = written + allocation.accountArrays.append((buf, written)) } - accountsBuffer = buf - allocation.accountArrays.append((buf, sortedAccounts.count)) } let cachedBalances = loadCachedBalancesOnQueue(walletId: w.walletId) @@ -2135,7 +2169,7 @@ public class PlatformWalletPersistenceHandler { copyBytes(w.walletId, into: &entry.wallet_id) entry.network = (w.network ?? .testnet).ffiValue entry.accounts = accountsBuffer.map { UnsafePointer($0) } - entry.accounts_count = UInt(sortedAccounts.count) + entry.accounts_count = UInt(accountsWritten) entry.platform_address_balances = addressBalancesBuffer.map { UnsafePointer($0) } entry.platform_address_balances_count = UInt(addressBalancesWritten) entry.platform_sync_height = syncState?.syncHeight ?? 0 @@ -2242,6 +2276,17 @@ public class PlatformWalletPersistenceHandler { // and the FFI handoff agree on the same 32-byte identity. let txid = record.txid guard txid.count == 32 else { continue } + // Reject UTXOs whose parent `accountType` (UInt32) doesn't + // fit in `u8`. Truncating would silently wrap a corrupt + // 0x100+ value into a potentially-valid tag in 0–255 and + // bypass Rust's `try_from_u8` validation. + guard let typeTagByte = UInt8(exactly: account.accountType) else { + NSLog( + "[persistor-load:swift] skipping UTXO row: accountType %u out of UInt8 range", + account.accountType + ) + continue + } // Allocate + copy the script_pubkey bytes. Empty scripts // pass through with a null pointer + zero len. @@ -2258,11 +2303,10 @@ public class PlatformWalletPersistenceHandler { } var utxo = UtxoRestoreEntryFFI() - // Match the existing AccountSpecFFI population pattern — - // cbindgen imports both tag enums as `UInt8` aliases, so - // assign the raw byte directly rather than constructing a - // `RawRepresentable`. - utxo.type_tag = UInt8(truncatingIfNeeded: account.accountType) + // Tag fields are FFI-typed `u8` and validated via + // `try_from_u8` on the Rust side; pass the exact byte + // we just guarded above. + utxo.type_tag = typeTagByte utxo.standard_tag = account.standardTag utxo.account_index = account.accountIndex utxo.registration_index = account.registrationIndex From 48eec7ee44d33acc23e6f14b697f568490faa053 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 12:49:01 +0700 Subject: [PATCH 08/10] fix(swift-sdk): fail load on corrupt persisted account tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 (thepastaclaw, codex-general): both spec-construction sites were `continue`ing past rows whose `accountType` (UInt32) didn't fit in `u8`, then handing Rust an entry with a truncated account / UTXO set while still reporting success. Result was a "successful restore" with silently-dropped funds-bearing accounts. Both writers now signal `errored = true` on `UInt8(exactly:)` failure: - account-spec writer: deallocates its scratch buffer + calls `allocation.release()` and returns from `loadWalletList` with `(nil, 0, true)` so the load callback returns 1 and Rust aborts. - `buildUtxoRestoreBuffer`: gained a third tuple element (`errored: Bool`); on error, deallocates its buffer and returns `(nil, 0, true)`. Caller in `loadWalletList` propagates by calling `allocation.release()` and returning errored. The persisted snapshot is corrupt in either case — refusing to drop rows surfaces it as a hard fail rather than letting half-loaded state through to the Rust manager. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWalletPersistenceHandler.swift | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 9db6a1d6572..3ae30111edf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2063,12 +2063,21 @@ public class PlatformWalletPersistenceHandler { // wrap a corrupt 0x100+ value into a potentially- // valid tag in the 0–255 range, defeating Rust's // `AccountTypeTagFFI::try_from_u8` validation. + // + // A `continue` here would silently drop a + // funds-bearing account from the snapshot and + // still report a successful restore — so abort + // the whole load callback instead. The Rust + // loader treats `errored = true` as a hard fail + // and won't construct a half-loaded manager. guard let typeTagByte = UInt8(exactly: acc.accountType) else { NSLog( - "[persistor-load:swift] skipping account row: accountType %u out of UInt8 range", + "[persistor-load:swift] aborting load: account row has accountType %u out of UInt8 range — refusing to silently drop it", acc.accountType ) - continue + buf.deallocate() + allocation.release() + return (nil, 0, true) } let xpubBuffer = UnsafeMutablePointer.allocate(capacity: xpub.count) xpub.copyBytes(to: xpubBuffer, count: xpub.count) @@ -2200,10 +2209,18 @@ public class PlatformWalletPersistenceHandler { // the matching funds-bearing account by tag; rows whose // account isn't a funds variant get silently skipped on // the receiving side. - let (utxoBuf, utxoCount) = buildUtxoRestoreBuffer( + let (utxoBuf, utxoCount, utxoErrored) = buildUtxoRestoreBuffer( rows: unspentBuckets[w.walletId] ?? [], allocation: allocation ) + // `buildUtxoRestoreBuffer` already deallocated its own + // buffer on the errored path; release everything else + // we've accumulated and abort the load callback so Rust + // doesn't see a partial / dropped-row snapshot. + if utxoErrored { + allocation.release() + return (nil, 0, true) + } entry.utxos = utxoBuf.map { UnsafePointer($0) } entry.utxos_count = UInt(utxoCount) // Primary-identity selection + gap-limit scan watermark @@ -2260,9 +2277,9 @@ public class PlatformWalletPersistenceHandler { private func buildUtxoRestoreBuffer( rows: [PersistentTxo], allocation: LoadAllocation - ) -> (UnsafeMutablePointer?, Int) { + ) -> (UnsafeMutablePointer?, Int, Bool) { if rows.isEmpty { - return (nil, 0) + return (nil, 0, false) } let buf = UnsafeMutablePointer.allocate(capacity: rows.count) var written = 0 @@ -2279,13 +2296,17 @@ public class PlatformWalletPersistenceHandler { // Reject UTXOs whose parent `accountType` (UInt32) doesn't // fit in `u8`. Truncating would silently wrap a corrupt // 0x100+ value into a potentially-valid tag in 0–255 and - // bypass Rust's `try_from_u8` validation. + // bypass Rust's `try_from_u8` validation. Drop-and-continue + // would also silently under-restore the funds set, so we + // signal `errored = true` and let `loadWalletList` fail + // the whole callback — the persisted snapshot is corrupt. guard let typeTagByte = UInt8(exactly: account.accountType) else { NSLog( - "[persistor-load:swift] skipping UTXO row: accountType %u out of UInt8 range", + "[persistor-load:swift] aborting load: UTXO has parent accountType %u out of UInt8 range — refusing to silently drop it", account.accountType ) - continue + buf.deallocate() + return (nil, 0, true) } // Allocate + copy the script_pubkey bytes. Empty scripts @@ -2328,10 +2349,10 @@ public class PlatformWalletPersistenceHandler { } if written == 0 { buf.deallocate() - return (nil, 0) + return (nil, 0, false) } allocation.utxoArrays.append((buf, written)) - return (buf, written) + return (buf, written, false) } private func buildIdentityRestoreBuffer( From a74717eda44e80307d83ae9e6d275077158af58e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 13:02:16 +0700 Subject: [PATCH 09/10] refactor(rs-platform-wallet-ffi): extract legacy-tag predicate helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 (coderabbit nit, 💤 low value but reasonable). Lifts the duplicated `type_tag == IdentityAuthenticationEcdsa as u8 || type_tag == IdentityAuthenticationBls as u8` check at the account loader and UTXO loader sites into a module-private `is_legacy_removed_account_tag(u8) -> bool` helper. If a third AccountType is ever deprecated, only the helper changes — both loader sites pick it up automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet-ffi/src/persistence.rs | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 6de736f4414..ef05a1af4a3 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1188,10 +1188,7 @@ fn build_wallet_start_state( let account_type = match account_type_from_spec(spec) { Ok(t) => t, Err(e) => { - let is_legacy_tag = spec.type_tag - == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 - || spec.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; - if is_legacy_tag { + if is_legacy_removed_account_tag(spec.type_tag) { tracing::warn!( wallet_id = %hex::encode(entry.wallet_id), type_tag = spec.type_tag, @@ -1291,10 +1288,7 @@ fn build_wallet_start_state( let account_type = match account_type_from_spec(&spec) { Ok(t) => t, Err(e) => { - let is_legacy_tag = u.type_tag - == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 - || u.type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8; - if is_legacy_tag { + if is_legacy_removed_account_tag(u.type_tag) { dropped_account_type += 1; tracing::warn!( wallet_id = %hex::encode(entry.wallet_id), @@ -1806,6 +1800,18 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result bool { + type_tag == AccountTypeTagFFI::IdentityAuthenticationEcdsa as u8 + || type_tag == AccountTypeTagFFI::IdentityAuthenticationBls as u8 +} + /// Read `len` bytes from a Swift-owned pointer as a `&[u8]`. /// /// # Safety From 14362140835cee1ac6e6c9f01a45151eaee02552 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 14:26:57 +0700 Subject: [PATCH 10/10] fix(swift-sdk): address round-8 review + add wallet metadata + utxo diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hard-fail load on corrupt UTXO txid (mirrors accountType handling). - Track LoadAllocation.entriesInitialized so abort paths only deinit written slots; pre-empts UB if a future field imports as non-trivial. - Refresh stale watch-only doc comments in persistence.rs and wallet_restore_types.rs (paths now reconstruct ExternalSignable). - Persist user-typed wallet metadata (name, description, networks, birthHeight) in the iOS keychain at wallet.metadata. so orphan recovery can restore the original label and birth height across reinstalls instead of falling back to "Recovered Wallet" / live tip. - Surface the metadata blob in the keychain explorer detail view. - TXOs storage list: filter (All / Unspent / Spent) + height column + sort by height descending. - Wallet Memory Explorer: per in-memory UTXO, render the matching PersistentTxo side by side; flag amount/height/spent/walletId mismatches in red and surface "orphan persisted UTXOs" — disk rows the wallet doesn't claim — for diagnosing the cascade-delete gap surfaced during balance regression triage. Test plan - ./build_ios.sh --target sim → BUILD SUCCEEDED - cargo check -p platform-wallet -p platform-wallet-ffi → clean - cargo fmt --all 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/manager_diagnostics.rs | 7 +- .../rs-platform-wallet-ffi/src/persistence.rs | 47 +-- .../src/wallet_restore_types.rs | 13 +- .../src/manager/accessors.rs | 17 +- .../src/wallet/identity/network/contacts.rs | 34 +- .../Core/Wallet/WalletStorage.swift | 183 ++++++++++- .../Persistence/Models/PersistentWallet.swift | 9 + .../PlatformWalletPersistenceHandler.swift | 48 ++- .../SwiftExampleApp/ContentView.swift | 70 ++++- .../Core/Views/CreateWalletView.swift | 33 +- .../Core/Views/WalletDetailView.swift | 58 +++- .../Views/KeychainExplorerView.swift | 109 ++++++- .../Views/StorageModelListViews.swift | 96 +++++- .../Views/WalletMemoryExplorerView.swift | 291 +++++++++++++++++- 14 files changed, 906 insertions(+), 109 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index b8986ae7dbd..a34ae66077d 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -506,8 +506,7 @@ pub unsafe extern "C" fn platform_wallet_account_address_pools_free( // Walk every per-address row first to release its // heap-owned `address` C string and `public_key_bytes` // buffer before the parent slice is reclaimed. - let addrs = - std::slice::from_raw_parts(entry.addresses, entry.addresses_count); + let addrs = std::slice::from_raw_parts(entry.addresses, entry.addresses_count); for a in addrs { if !a.address.is_null() { let _ = CString::from_raw(a.address); @@ -824,8 +823,8 @@ fn account_type_from_spec_ref( })?; Ok(match type_tag { AccountTypeTagFFI::Standard => { - let standard_tag = - StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag).ok_or_else(|| { + let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag) + .ok_or_else(|| { format!( "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", spec.standard_tag diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index ef05a1af4a3..c2c277372e5 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -42,8 +42,7 @@ use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI}; use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - LoadWalletListFreeFn, StandardAccountTypeTagFFI, UtxoRestoreEntryFFI, - WalletRestoreEntryFFI, + LoadWalletListFreeFn, StandardAccountTypeTagFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -143,7 +142,11 @@ pub struct PersistenceCallbacks { ) -> i32, >, /// Invoked on [`FFIPersister::load`] to pull the persisted wallet - /// list back into Rust for watch-only reconstruction. + /// list back into Rust for external-signable reconstruction. + /// (The function name still reads "watch-only" in older docs; the + /// reconstructed `Wallet` is built via + /// `Wallet::new_external_signable` so the signer surface routes + /// back to the host's keychain.) /// /// Implementations must set `*out_entries` to a Swift-allocated /// array of `WalletRestoreEntryFFI` and `*out_count` to the @@ -1150,8 +1153,12 @@ impl Drop for LoadGuard { } } -/// Reconstruct a watch-only [`Wallet`] + matching start-state bucket -/// from a single `WalletRestoreEntryFFI`. +/// Reconstruct an external-signable [`Wallet`] + matching start-state +/// bucket from a single `WalletRestoreEntryFFI`. The mnemonic / seed +/// stays in the host's keychain; signing requests route back through +/// the configured signer surface (see +/// `Wallet::new_external_signable`). Earlier revisions of this code +/// path produced a `WatchOnly` wallet — that has been replaced. fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -1308,10 +1315,7 @@ fn build_wallet_start_state( ); continue; }; - let outpoint = dashcore::OutPoint { - txid, - vout: u.vout, - }; + let outpoint = dashcore::OutPoint { txid, vout: u.vout }; let script_pubkey = dashcore::ScriptBuf::from_bytes(script_bytes.to_vec()); let Ok(address) = dashcore::Address::from_script(&script_pubkey, network) else { dropped_bad_script += 1; @@ -1360,26 +1364,24 @@ fn build_wallet_start_state( index, user_identity_id, friend_identity_id, - } => wallet_info - .accounts - .dashpay_receival_accounts - .get_mut(&key_wallet::account::account_collection::DashpayAccountKey { + } => wallet_info.accounts.dashpay_receival_accounts.get_mut( + &key_wallet::account::account_collection::DashpayAccountKey { index, user_identity_id, friend_identity_id, - }), + }, + ), AccountType::DashpayExternalAccount { index, user_identity_id, friend_identity_id, - } => wallet_info - .accounts - .dashpay_external_accounts - .get_mut(&key_wallet::account::account_collection::DashpayAccountKey { + } => wallet_info.accounts.dashpay_external_accounts.get_mut( + &key_wallet::account::account_collection::DashpayAccountKey { index, user_identity_id, friend_identity_id, - }), + }, + ), _ => None, }; if let Some(funds_account) = target_funds { @@ -1394,8 +1396,7 @@ fn build_wallet_start_state( ); } } - let dropped = - dropped_account_type + dropped_bad_txid + dropped_bad_script + dropped_no_account; + let dropped = dropped_account_type + dropped_bad_txid + dropped_bad_script + dropped_no_account; if dropped > 0 { // Surface a single rollup line so operators see the totals // even with `tracing` set to ERROR-only (the per-row warns @@ -1735,8 +1736,8 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - let standard_tag = - StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag).ok_or_else(|| { + let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag) + .ok_or_else(|| { PersistenceError::Backend(format!( "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", spec.standard_tag diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index cc82db583b3..3e1d82567fa 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -1,11 +1,16 @@ -//! C-compatible types for watch-only wallet restore via the load-side -//! callbacks on [`PersistenceCallbacks`](crate::persistence::PersistenceCallbacks). +//! C-compatible types for external-signable wallet restore via the +//! load-side callbacks on +//! [`PersistenceCallbacks`](crate::persistence::PersistenceCallbacks). //! //! On write: `on_persist_account_registrations_fn` fires with the //! `AccountSpecFFI` shape so Swift can store accounts in SwiftData. //! On load: `on_load_wallet_list_fn` returns an array of -//! `WalletRestoreEntryFFI` which Rust assembles into a watch-only -//! `Wallet` via `Wallet::new_watch_only` + per-account `Account::from_xpub`. +//! `WalletRestoreEntryFFI` which Rust assembles into an +//! external-signable `Wallet` via `Wallet::new_external_signable` + +//! per-account `Account::from_xpub`. (The mnemonic stays in the +//! host's keychain; signing routes back through the configured +//! signer surface. Earlier revisions reconstructed a `WatchOnly` +//! wallet — that path has been replaced.) //! //! All `*const u8` pointers must stay valid for the duration of the //! load callback. Swift owns the allocation and is asked to free it diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 00600f66fa2..ed9cf89964f 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -266,10 +266,7 @@ impl PlatformWalletManager

{ /// 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 { + pub fn account_balances_blocking(&self, wallet_id: &WalletId) -> Vec { let wm = self.wallet_manager.blocking_read(); let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); @@ -282,10 +279,7 @@ impl PlatformWalletManager

{ // Balance lives on the funds-bearing variant only; // keys-only accounts (identity, asset-lock, provider) // never carry UTXOs. - let balance = account - .as_funds() - .map(|a| a.balance) - .unwrap_or_default(); + let balance = account.as_funds().map(|a| a.balance).unwrap_or_default(); // Walk every pool on the account, sum // `used` + total entries. Cheap — pools are bounded by // the gap limit. @@ -294,11 +288,8 @@ impl PlatformWalletManager

{ .address_pools() .iter() .fold((0u32, 0u32), |(used, total), pool| { - let pool_used = pool - .addresses - .values() - .filter(|info| info.used) - .count() as u32; + let pool_used = + pool.addresses.values().filter(|info| info.used).count() as u32; let pool_total = pool.addresses.len() as u32; (used + pool_used, total + pool_total) }); 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 0044d992854..1ac523757e9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -141,13 +141,15 @@ impl IdentityWallet { // DashPay accounts are funds-bearing; use the typed // `insert_funds` API exposed by the post-split collection // rather than wrapping in `OwnedManagedCoreAccount`. - let managed = - key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); - info.core_wallet.accounts.insert_funds(managed).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register contact account: {e}" - )) - })?; + let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); + info.core_wallet + .accounts + .insert_funds(managed) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register contact account: {e}" + )) + })?; Ok(()) } @@ -474,8 +476,7 @@ impl IdentityWallet { // DashpayExternalAccount is funds-bearing; insert via the // typed `insert_funds` API after the upstream split. - let managed = - key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); + let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm @@ -494,12 +495,15 @@ impl IdentityWallet { })?; // (b) Insert ManagedCoreFundsAccount for address-pool tracking. - info.core_wallet.accounts.insert_funds(managed).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register external contact account: {}", - e - )) - })?; + info.core_wallet + .accounts + .insert_funds(managed) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register external contact account: {}", + e + )) + })?; tracing::info!( our_identity = %our_identity_id, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index d3b95cb3e49..cb6923c7e71 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -19,6 +19,11 @@ import Security /// /// * Per-wallet mnemonic storage at /// `wallet.mnemonic.<64-char-hex-walletId>`. +/// * Per-wallet user-facing metadata (display name + free-form +/// description) at `wallet.metadata.<64-char-hex-walletId>`, +/// carried as a JSON-encoded `WalletKeychainMetadata` blob so the +/// orphan-mnemonic recovery flow can repopulate the SwiftData row +/// with the original name/description after a reinstall. /// * Enumeration of stored wallet ids (used by the orphan-mnemonic /// recovery flow in `ContentView`). /// * Biometric-protected seed stash at `wallet.biometric` — not yet @@ -42,6 +47,12 @@ public class WalletStorage { /// single-mnemonic row at the bare `"wallet.mnemonic"` account /// is no longer stored — see `cleanupLegacyItems`. private let mnemonicKeychainAccount = "wallet.mnemonic" + /// Base account string used to build per-wallet metadata + /// accounts via `perWalletMetadataAccount(for:)`. Read by the + /// orphan-mnemonic recovery flow so reinstalls can restore the + /// user-facing wallet name and description from the keychain + /// even though SwiftData was wiped. + public static let metadataAccountPrefix = "wallet.metadata" private let biometricKeychainAccount = "wallet.biometric" public init() {} @@ -190,6 +201,92 @@ public class WalletStorage { return walletIds } + // MARK: - Per-Wallet Metadata Storage + // + // User-facing display strings (name + description) carried in + // the keychain so reinstalls / orphan-mnemonic recovery can + // repopulate the corresponding `PersistentWallet` row. The blob + // is intentionally tiny — only fields the user typed should + // live here, not derived/cached state like sync heights. + + private func perWalletMetadataAccount(for walletId: Data) -> String { + let hex = walletId.map { String(format: "%02x", $0) }.joined() + return "\(Self.metadataAccountPrefix).\(hex)" + } + + /// Write (or replace) the metadata blob for `walletId`. Uses the + /// delete-then-add pattern matching `storeMnemonic` so the + /// `kSecAttrAccessible` value is rewritten on every save. + public func setMetadata(_ metadata: WalletKeychainMetadata, for walletId: Data) throws { + let data = try JSONEncoder().encode(metadata) + let account = perWalletMetadataAccount(for: walletId) + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account + ] + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw WalletStorageError.keychainError(deleteStatus) + } + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw WalletStorageError.keychainError(status) + } + } + + /// Read back the metadata blob for `walletId`. Returns `nil` on + /// `errSecItemNotFound` so the orphan-recovery flow can + /// distinguish "no metadata stored" from a hard keychain error. + public func metadata(for walletId: Data) throws -> WalletKeychainMetadata? { + let account = perWalletMetadataAccount(for: walletId) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account, + kSecReturnData as String: true + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw WalletStorageError.keychainError(status) + } + guard let data = result as? Data, !data.isEmpty else { + return nil + } + // Decode failures here mean a corrupted blob — treat as + // "no metadata available" rather than a hard error so the + // wallet can still be recovered. The caller logs and falls + // back to the placeholder name. + return try? JSONDecoder().decode(WalletKeychainMetadata.self, from: data) + } + + /// Delete the metadata blob keyed by `walletId`. Idempotent. + public func deleteMetadata(for walletId: Data) throws { + let account = perWalletMetadataAccount(for: walletId) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw WalletStorageError.keychainError(status) + } + } + /// Decode a lowercase hex string into bytes. Returns nil on any /// non-hex character or an odd length. private static func dataFromHex(_ hex: String) -> Data? { @@ -287,10 +384,11 @@ public class WalletStorage { /// /// Safe to call on a fresh install — each `SecItemDelete` /// returns `errSecItemNotFound` and the method ignores every - /// status. Per-wallet mnemonic rows under the current service - /// (`wallet.mnemonic.`) are unaffected because - /// the per-account deletions match on the full account string, - /// not a prefix. + /// status. Per-wallet rows under the current service + /// (`wallet.mnemonic.` and the matching + /// `wallet.metadata.` blobs written by + /// `setMetadata`) are unaffected because the per-account + /// deletions match on the full account string, not a prefix. /// /// Called once per launch from `SwiftExampleAppApp.bootstrap`. /// @@ -325,6 +423,83 @@ public class WalletStorage { } } +// MARK: - Wallet Keychain Metadata + +/// User-facing / user-intent wallet metadata persisted in the +/// keychain so it can be carried across SwiftData wipes (orphan +/// recovery, app reinstalls). Intentionally minimal — only fields +/// the user explicitly chose (name, description, networks) plus +/// values the user can't recompute cheaply (`birthHeight`, which +/// the SPV tip at creation locks in for the lifetime of the +/// wallet). Derived / cached state like `syncedHeight` belongs in +/// SwiftData, not here. +public struct WalletKeychainMetadata: Codable, Equatable { + /// Display name the user assigned to the wallet, if any. + public var name: String? + /// Optional free-form description the user typed alongside the + /// name. Currently no UI to set this — the field is wired + /// through so future write paths can populate it without a + /// schema migration. + public var walletDescription: String? + /// Networks the user explicitly enabled on this wallet, as + /// stable string codes (`Network.networkName` — + /// `"mainnet"` / `"testnet"` / `"devnet"` / `"regtest"`). + /// Strings rather than raw `UInt32`s so a future enum addition + /// doesn't crash old clients reading new blobs — unknown codes + /// just get filtered out at decode time. `nil` on rows written + /// before this field landed; recovery falls back to its old + /// testnet default. + public var networks: [String]? + /// SPV chain tip at the moment the wallet was originally + /// created. The first SPV scan starts from this height instead + /// of genesis, so preserving it across a reinstall avoids + /// re-scanning years of irrelevant history. `nil` for blobs + /// written before this field landed (or for imported wallets + /// where we don't yet capture a genesis-distance estimate); + /// callers fall back to the live SPV tip in that case. + public var birthHeight: UInt32? + + public init( + name: String? = nil, + walletDescription: String? = nil, + networks: [String]? = nil, + birthHeight: UInt32? = nil + ) { + self.name = name + self.walletDescription = walletDescription + self.networks = networks + self.birthHeight = birthHeight + } + + /// Decoded `networks` array as `Network` values, dropping any + /// strings that don't match a known case so unknown future + /// codes don't blow up recovery on an older client. + public var resolvedNetworks: [Network] { + guard let networks else { return [] } + return networks.compactMap { code in + switch code.lowercased() { + case "mainnet": return .mainnet + case "testnet": return .testnet + case "devnet": return .devnet + case "regtest": return .regtest + default: return nil + } + } + } + + /// JSON keys are stable and short — `description` collides with + /// `CustomStringConvertible.description` on the Swift side but + /// is the natural name on disk, so we do the rename in the + /// `CodingKeys`. `birthHeight` is camel-cased to match the JS / + /// SwiftData side, both of which already use that spelling. + private enum CodingKeys: String, CodingKey { + case name + case walletDescription = "description" + case networks + case birthHeight + } +} + // MARK: - Wallet Storage Errors public enum WalletStorageError: LocalizedError { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index df6503c09d8..46188f34dc3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -36,6 +36,13 @@ public final class PersistentWallet { } /// Optional wallet name. public var name: String? + /// Optional free-form user-supplied description. Mirrored into + /// the keychain metadata blob (see `WalletKeychainMetadata`) so + /// it survives a SwiftData wipe / reinstall via the + /// orphan-mnemonic recovery flow. No UI surfaces this yet, but + /// the column is wired so existing rows roll forward without a + /// schema migration when it lands. + public var walletDescription: String? /// Birth height — block height when the wallet was created. public var birthHeight: UInt32 /// Last synced core block height. @@ -71,6 +78,7 @@ public final class PersistentWallet { walletId: Data, network: Network? = nil, name: String? = nil, + walletDescription: String? = nil, birthHeight: UInt32 = 0, syncedHeight: UInt32 = 0, isImported: Bool = false @@ -78,6 +86,7 @@ public final class PersistentWallet { self.walletId = walletId self.networkRaw = network?.rawValue self.name = name + self.walletDescription = walletDescription self.birthHeight = birthHeight self.syncedHeight = syncedHeight self.lastSynced = 0 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 3ae30111edf..e158d88d260 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2228,6 +2228,11 @@ public class PlatformWalletPersistenceHandler { // Rust manager (UI owns selection now, scan resume is // derived from the highest already-registered slot). entriesPtr[i] = entry + // Bump the initialized-count so a later abort path's + // `release()` only deinitializes slots that were + // actually written (see `entriesInitialized`'s + // doc-comment for why we can't reuse `entriesCount`). + allocation.entriesInitialized = i + 1 } let typed = UnsafePointer(entriesPtr) @@ -2291,8 +2296,23 @@ public class PlatformWalletPersistenceHandler { // accessor, which prefers `transaction.txid` and falls // back to `outpoint.prefix(32)` so storage-explorer rows // and the FFI handoff agree on the same 32-byte identity. + // + // A row whose `txid` doesn't measure 32 bytes is corrupt + // by construction (the model guarantees the prefix on + // every write). Treat it the same way as the corrupt + // `accountType` case below — abort the whole load so the + // caller can surface the error rather than silently + // under-restoring the funds set. Symmetric handling + // keeps the restore contract uniform. let txid = record.txid - guard txid.count == 32 else { continue } + guard txid.count == 32 else { + NSLog( + "[persistor-load:swift] aborting load: UTXO has txid of %d bytes (expected 32) — refusing to silently drop it", + txid.count + ) + buf.deallocate() + return (nil, 0, true) + } // Reject UTXOs whose parent `accountType` (UInt32) doesn't // fit in `u8`. Truncating would silently wrap a corrupt // 0x100+ value into a potentially-valid tag in 0–255 and @@ -2573,7 +2593,23 @@ public class PlatformWalletPersistenceHandler { /// `loadWalletList` call. Released wholesale by `loadWalletListFree`. private final class LoadAllocation { var entries: UnsafeMutablePointer? + /// Allocated capacity — equal to `restorable.count`. Used for + /// `deallocate()` (which only requires "the original allocation + /// size") and as the upper bound on `entriesInitialized`. var entriesCount: Int = 0 + /// How many of the `entriesCount` slots have actually been + /// written via `entriesPtr[i] = entry`. Tracked separately from + /// `entriesCount` because early-abort paths (account-tag + /// overflow, UTXO marshalling failure) call `release()` after + /// only `0.., Int)] = [] /// `AddressBalanceEntryFFI` arrays per wallet. @@ -2603,7 +2639,15 @@ private final class LoadAllocation { func release() { if let entries = entries { - entries.deinitialize(count: entriesCount) + // Deinitialize ONLY the slots that were actually written + // (`entriesInitialized`), then deallocate the full + // capacity (`entriesCount`). Per Swift's pointer + // contract, `deinitialize(count:)` requires the region + // to be initialized; `deallocate()` only requires the + // pointer to match the original allocation. + if entriesInitialized > 0 { + entries.deinitialize(count: entriesInitialized) + } entries.deallocate() } for (ptr, count) in accountArrays { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 2bb6b5cb9b2..80ae6d9470c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -246,27 +246,51 @@ struct ContentView: View { return } + let storage = WalletStorage() let mnemonic: String do { - mnemonic = try WalletStorage().retrieveMnemonic(for: walletId) + mnemonic = try storage.retrieveMnemonic(for: walletId) } catch { recoveryError = "Failed to read stored mnemonic: \(error.localizedDescription)" return } + // Pull the user-facing name + description + networks + + // birth height out of the keychain (written alongside the + // mnemonic at wallet creation time). If the metadata blob + // is missing — older installs that predate this feature — + // fall back to the generic "Recovered Wallet" placeholder + // and the previous testnet default so the row is still + // clickable. + let restoredMetadata: WalletKeychainMetadata? = + (try? storage.metadata(for: walletId)) ?? nil + let restoredName: String = { + guard let raw = restoredMetadata?.name else { return "Recovered Wallet" } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Recovered Wallet" : trimmed + }() + let restoredDescription: String? = restoredMetadata?.walletDescription + // First valid stored network wins; the + // `walletManager.createWallet` API still takes a single + // network. Multi-network support is a TODO on the Rust + // side (`WalletDetailView.swift:533`) — when it lands the + // full `restoredMetadata?.resolvedNetworks` list is + // already there to feed in. + let restoredNetwork: Network = + restoredMetadata?.resolvedNetworks.first ?? .testnet + let restoredBirthHeight: UInt32? = restoredMetadata?.birthHeight + do { - // Default the restored wallet to testnet with a - // recognizable label. The user can rename via the - // wallet list afterwards. The `PersistentWallet` row - // is created by the persister callback downstream of - // `walletManager.createWallet` — we only need to - // stamp the `isImported` flag here. - let platformNetwork: Network = .testnet - let label = "Recovered Wallet" + // The `PersistentWallet` row is created by the + // persister callback downstream of + // `walletManager.createWallet`; we only need to stamp + // the `isImported` flag and the carried-over + // description / birth height on top of what the + // persister wrote. let managed = try walletManager.createWallet( mnemonic: mnemonic, - network: platformNetwork, - name: label + network: restoredNetwork, + name: restoredName ) let walletIdMatch = managed.walletId let descriptor = FetchDescriptor( @@ -274,6 +298,19 @@ struct ContentView: View { ) if let row = try? modelContext.fetch(descriptor).first { row.isImported = true + if row.walletDescription == nil { + row.walletDescription = restoredDescription + } + // Persisted birth height wins over the synthetic + // value the persister stamped from the live SPV + // tip — otherwise a recovered wallet would scan + // forward from "now" and lose all transactions + // older than the recovery moment. Skip the + // override only if we have no record (`nil` → + // keep whatever the persister wrote). + if let stored = restoredBirthHeight { + row.birthHeight = stored + } try? modelContext.save() } advanceToNextOrphan() @@ -283,12 +320,19 @@ struct ContentView: View { } /// Remove the currently-selected orphan's mnemonic from the - /// keychain and advance to the next orphan in the queue. + /// keychain and advance to the next orphan in the queue. Also + /// drops any associated keychain metadata blob so the row is + /// fully cleared. @MainActor private func deleteStoredMnemonic() { guard let walletId = pendingOrphans.first else { return } do { - try WalletStorage().deleteMnemonic(for: walletId) + let storage = WalletStorage() + try storage.deleteMnemonic(for: walletId) + // Best-effort: metadata follows the mnemonic. If this + // fails the metadata row is harmless (it has no secret + // material and gets overwritten on the next setMetadata). + try? storage.deleteMetadata(for: walletId) advanceToNextOrphan() } catch { recoveryError = "Failed to delete mnemonic: \(error.localizedDescription)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index e8a9171605c..6adc08609bc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -313,8 +313,9 @@ struct CreateWalletView: View { // recovery flow can enumerate all of them on // launch. Best-effort — failure here doesn't // block wallet creation. + let storage = WalletStorage() do { - try WalletStorage().storeMnemonic( + try storage.storeMnemonic( mnemonicPhrase, for: managed.walletId ) @@ -338,10 +339,38 @@ struct CreateWalletView: View { let descriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletIdMatch } ) - if let row = try? modelContext.fetch(descriptor).first { + let row = try? modelContext.fetch(descriptor).first + if let row = row { row.isImported = showImportOption try? modelContext.save() } + // Mirror the user-typed name + the networks the + // user explicitly ticked + the SPV-tip-derived + // birth height into the keychain alongside the + // mnemonic. Read back by the orphan-mnemonic + // recovery flow so a wipe + reinstall restores + // the original label / networks / birth height + // instead of resurrecting the wallet on testnet + // with a synthetic genesis. + // + // `selectedNetworks` carries every network the + // user ticked even though `walletManager` only + // currently consumes the first; persisting the + // full list now means the multi-network TODO on + // the Rust side won't need a metadata migration. + do { + let metadata = WalletKeychainMetadata( + name: walletLabel, + walletDescription: nil, + networks: selectedNetworks.map { $0.networkName }, + birthHeight: row?.birthHeight + ) + try storage.setMetadata(metadata, for: managed.walletId) + } catch { + SDKLogger.error( + "Failed to persist wallet metadata to keychain: \(error.localizedDescription)" + ) + } dismiss() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index d272e54a72d..ee94dabfb97 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -516,13 +516,62 @@ struct WalletInfoView: View { // `label` is a computed fallback; the writable backing // field is `name`. Empty-string means "unnamed"; the // computed `label` then falls back to the hex fingerprint. - wallet.name = editedName.isEmpty ? nil : editedName + let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines) + let newName: String? = trimmed.isEmpty ? nil : trimmed + wallet.name = newName do { try modelContext.save() isEditingName = false } catch { errorMessage = "Failed to save wallet name: \(error.localizedDescription)" showError = true + return + } + // Mirror the rename into the keychain metadata blob so a + // future reinstall / orphan-recovery picks up the new + // label instead of resurrecting the old one (or the + // "Recovered Wallet" placeholder when the original name + // was never written). Read the existing blob first so the + // `networks` and `birthHeight` fields round-trip — those + // get filled in at creation time and the rename UI has no + // business overwriting them with stale values from the + // SwiftData row. Falls back to a freshly-built blob if + // none exists yet (older installs that predate the + // metadata feature). + let storage = WalletStorage() + let walletId = wallet.walletId + var metadata: WalletKeychainMetadata + do { + metadata = try storage.metadata(for: walletId) + ?? WalletKeychainMetadata() + } catch { + metadata = WalletKeychainMetadata() + } + metadata.name = newName + metadata.walletDescription = wallet.walletDescription + // Backfill `networks` from the SwiftData row when the + // existing blob is missing it. `PersistentWallet` is + // currently single-network, so the best we can do here is + // a one-element list. When multi-network support lands on + // the Rust side this can be widened. + if metadata.networks == nil, let net = wallet.network { + metadata.networks = [net.networkName] + } + // Same backfill story for `birthHeight` — older blobs + // missed it; we have the SwiftData copy on hand so push + // it in once. + if metadata.birthHeight == nil { + metadata.birthHeight = wallet.birthHeight + } + do { + try storage.setMetadata(metadata, for: walletId) + } catch { + // Non-fatal: SwiftData already has the new name; this + // only affects orphan-recovery after a wipe. Surface + // through the logger instead of blocking the UI. + SDKLogger.error( + "Failed to update wallet metadata in keychain: \(error.localizedDescription)" + ) } } @@ -554,7 +603,12 @@ struct WalletInfoView: View { modelContext.delete(wallet) do { try modelContext.save() - try WalletStorage().deleteMnemonic(for: walletId) + let storage = WalletStorage() + try storage.deleteMnemonic(for: walletId) + // Keychain metadata is independent of the mnemonic + // row — clear it here so a deleted wallet doesn't + // leave stale name/description behind. + try storage.deleteMetadata(for: walletId) } catch { modelContext.rollback() SDKLogger.error( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeychainExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeychainExplorerView.swift index 95c15f2925d..c348a481d31 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeychainExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeychainExplorerView.swift @@ -215,6 +215,7 @@ enum Category: CaseIterable, Hashable { case legacyIdentityPrivateKey case specialKey case walletMnemonic + case walletMetadata case biometric case other @@ -224,6 +225,7 @@ enum Category: CaseIterable, Hashable { case .legacyIdentityPrivateKey: return "Identity Private Keys (legacy)" case .specialKey: return "Special Keys (Voting / Owner / Payout)" case .walletMnemonic: return "Per-Wallet Mnemonics" + case .walletMetadata: return "Per-Wallet Metadata" case .biometric: return "Biometric Material" case .other: return "Other" } @@ -235,6 +237,7 @@ enum Category: CaseIterable, Hashable { case .legacyIdentityPrivateKey: return "key" case .specialKey: return "key.icloud" case .walletMnemonic: return "doc.text" + case .walletMetadata: return "tag" case .biometric: return "faceid" case .other: return "questionmark.square.dashed" } @@ -249,6 +252,11 @@ enum Category: CaseIterable, Hashable { // derivation-path-keyed layout above. if account.hasPrefix("privkey_") { return .legacyIdentityPrivateKey } if account.hasPrefix("specialkey_") { return .specialKey } + // Order matters: `wallet.metadata.` must be matched before + // `wallet.mnemonic.` because both share the `wallet.` + // prefix; the explorer relies on the trailing namespace + // segment to disambiguate. + if account.hasPrefix("\(WalletStorage.metadataAccountPrefix).") { return .walletMetadata } if account.hasPrefix("wallet.mnemonic.") { return .walletMnemonic } if account == "wallet.biometric" { return .biometric } return .other @@ -288,6 +296,10 @@ enum Category: CaseIterable, Hashable { case .walletMnemonic: let hex = String(account.dropFirst("wallet.mnemonic.".count)) return "Wallet \(shortHex(hex))" + case .walletMetadata: + let prefix = "\(WalletStorage.metadataAccountPrefix)." + let hex = String(account.dropFirst(prefix.count)) + return "Wallet \(shortHex(hex)) · metadata" case .biometric: return "Biometric" case .other: @@ -308,6 +320,13 @@ enum Category: CaseIterable, Hashable { struct KeychainItemDetailView: View { let item: KeychainItemSummary + /// Decoded `WalletKeychainMetadata` blob for `walletMetadata` + /// rows. Pulled lazily on `.onAppear` so the rest of the detail + /// view (which only renders attribute metadata) doesn't pay + /// the cost of touching the keychain value path on every cell. + /// Stays `nil` for non-metadata rows. + @State private var walletMetadata: WalletMetadataPreview? + var body: some View { List { Section("Identity") { @@ -351,12 +370,45 @@ struct KeychainItemDetailView: View { } } + // For wallet-metadata rows the stored value is the + // user-typed name + description + networks + birth + // height (NOT a secret), so we surface its decoded + // form here. Mnemonic / private-key rows fall through + // and are never read. + if Category.from(item.account) == .walletMetadata, + let preview = walletMetadata { + Section("Wallet metadata") { + if let name = preview.name { + labeledRow("Name", name) + } else { + labeledRow("Name", "(none)") + } + if let desc = preview.walletDescription { + labeledRow("Description", desc) + } else { + labeledRow("Description", "(none)") + } + if let nets = preview.networks, !nets.isEmpty { + labeledRow("Networks", nets.joined(separator: ", ")) + } else { + labeledRow("Networks", "(none)") + } + if let bh = preview.birthHeight { + labeledRow("Birth height", String(bh)) + } else { + labeledRow("Birth height", "(none)") + } + } + } + Section { Text( - "Key material is never read by this explorer — rows " - + "show keychain attribute metadata only. To extract a " - + "value you'd have to call the owning API path " - + "(KeychainManager / WalletStorage) directly." + "Secret material (mnemonics, private keys) is " + + "never read by this explorer — rows show keychain " + + "attribute metadata only. The wallet-metadata " + + "category surfaces its plain-text payload because " + + "the user typed those strings; everything else " + + "stays opaque." ) .font(.caption2) .foregroundColor(.secondary) @@ -364,6 +416,55 @@ struct KeychainItemDetailView: View { } .navigationTitle("Keychain Item") .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadWalletMetadataIfNeeded) + } + + /// On first appear of a `walletMetadata` row, decode the value + /// blob into `WalletKeychainMetadata` and snapshot it locally. + /// Non-metadata rows are no-ops. + private func loadWalletMetadataIfNeeded() { + guard walletMetadata == nil, + Category.from(item.account) == .walletMetadata else { return } + let prefix = "\(WalletStorage.metadataAccountPrefix)." + guard item.account.hasPrefix(prefix) else { return } + let hex = String(item.account.dropFirst(prefix.count)) + guard let walletId = Self.dataFromHex(hex) else { return } + let storage = WalletStorage() + guard let stored = (try? storage.metadata(for: walletId)) ?? nil else { return } + walletMetadata = WalletMetadataPreview( + name: stored.name, + walletDescription: stored.walletDescription, + networks: stored.networks, + birthHeight: stored.birthHeight + ) + } + + /// Local hex decoder so the explorer doesn't depend on the + /// private decoder inside `WalletStorage`. Safe to call on the + /// trimmed account suffix because `Category.from` already + /// validated the prefix. + private static func dataFromHex(_ hex: String) -> Data? { + guard hex.count % 2 == 0 else { return nil } + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. String { private struct KVRow: View { let label: String let value: String + /// Optional override for the value text color. Used by the + /// SwiftData-counterpart diagnostic to paint mismatch rows red + /// (or orange for "linked weakly") so reviewers can scan a long + /// list without expanding every entry. Defaults to the system + /// foreground color when nil so prior call sites stay unchanged. + var valueColor: Color? = nil var body: some View { HStack(alignment: .firstTextBaseline) { @@ -149,6 +156,7 @@ private struct KVRow: View { .truncationMode(.middle) .textSelection(.enabled) .font(.system(.body, design: .monospaced)) + .foregroundColor(valueColor) } } } @@ -926,10 +934,29 @@ struct AccountDrillDownView: View { let walletId: Data let balance: PlatformWalletManager.AccountBalance @EnvironmentObject var walletManager: PlatformWalletManager + /// SwiftData context — used to cross-check every in-memory UTXO + /// against its persisted `PersistentTxo` counterpart and surface + /// the side-by-side diff in the explorer. Mismatches point at + /// real bugs (orphan rows from incomplete cascades, lingering + /// `isSpent == false` rows that the wallet has already evicted, + /// out-of-sync amount / height fields, etc.). + @Environment(\.modelContext) private var modelContext @State private var metadata: PlatformWalletManager.AccountMetadataSnapshot? @State private var pools: [PlatformWalletManager.AccountAddressPool] = [] @State private var utxos: [PlatformWalletManager.AccountUtxo] = [] + /// Snapshot of `PersistentTxo` rows for this wallet+account that + /// SwiftData reports as unspent. Keyed by 36-byte outpoint + /// (`PersistentTxo.makeOutpoint`) so each in-memory UTXO can do + /// an O(1) lookup. Refreshed alongside `utxos` in `load()`. + @State private var persistedTxosByOutpoint: [Data: TxoSnapshot] = [:] + /// Persisted rows the wallet doesn't currently claim — i.e., + /// `PersistentTxo` rows for this wallet+account where + /// `isSpent == false` but the outpoint isn't in the in-memory + /// UTXO set. The orphan signature for the persistence / + /// cascade-delete bug surfaced during the run-1 → fresh-load + /// regression diagnosis. + @State private var orphanPersistedTxos: [TxoSnapshot] = [] /// Whether this account is the keys-only variant — drives whether /// UTXO-related surfaces are shown. UTXOs are exclusive to the @@ -952,6 +979,7 @@ struct AccountDrillDownView: View { addressPoolsSection if !isKeysAccount { utxosSection + orphanPersistedSection } // Per-account in-memory transaction list intentionally // omitted: `keep_txs_in_memory` is off and tx history is @@ -1087,19 +1115,102 @@ struct AccountDrillDownView: View { } else { ForEach(Array(utxos.enumerated()), id: \.offset) { _, u in DisclosureGroup { + // In-memory side (Rust-owned, what the wallet + // currently believes about this UTXO). + Text("In-memory (Rust)") + .font(.caption2) + .foregroundColor(.secondary) KVRow(label: "Value", value: formatDuffs(u.valueDuffs)) KVRow(label: "Height", value: "\(u.height)") KVRow(label: "Locked", value: u.isLocked ? "yes" : "no") KVRow(label: "Script Len", value: "\(u.scriptPubkey.count)") + + Divider() + + // SwiftData side. The persistence handler + // upserts a `PersistentTxo` for each emit; if + // the row is missing here the in-memory wallet + // is ahead of disk (recent receive that + // hasn't flushed yet). If the row is present + // but flagged `isSpent`, that's a real + // disagreement worth investigating. + Text("SwiftData (PersistentTxo)") + .font(.caption2) + .foregroundColor(.secondary) + let outpointKey = PersistentTxo.makeOutpoint( + txid: u.outpointTxid, + vout: u.outpointVout + ) + if let p = persistedTxosByOutpoint[outpointKey] { + KVRow( + label: "Amount", + value: formatDuffs(p.amount), + valueColor: p.amount == u.valueDuffs ? nil : .red + ) + KVRow( + label: "Height", + value: "\(p.height)", + valueColor: p.height == u.height ? nil : .red + ) + KVRow( + label: "isSpent", + value: p.isSpent ? "yes (DISAGREE)" : "no", + valueColor: p.isSpent ? .red : nil + ) + KVRow(label: "isConfirmed", value: p.isConfirmed ? "yes" : "no") + KVRow(label: "isCoinbase", value: p.isCoinbase ? "yes" : "no") + KVRow(label: "isInstantLocked", value: p.isInstantLocked ? "yes" : "no") + KVRow(label: "isLocked", value: p.isLocked ? "yes" : "no") + KVRow( + label: "Address", + value: p.address.isEmpty ? "(none)" : p.address + ) + KVRow( + label: "wallet match", + value: p.walletIdMatches ? "yes" : "no", + valueColor: p.walletIdMatches ? nil : .red + ) + KVRow( + label: "account linked", + value: p.hasAccountLink ? "yes" : "no", + valueColor: p.hasAccountLink ? nil : .orange + ) + KVRow( + label: "coreAddress linked", + value: p.hasCoreAddressLink ? "yes" : "no", + valueColor: p.hasCoreAddressLink ? nil : .orange + ) + } else { + Text("Not in SwiftData (in-memory ahead of disk, or never persisted)") + .font(.caption) + .foregroundColor(.red) + } } label: { + let outpointKey = PersistentTxo.makeOutpoint( + txid: u.outpointTxid, + vout: u.outpointVout + ) + let mismatchKind: String? = persistedTxosByOutpoint[outpointKey] + .map { snap in mismatchSummary(persisted: snap, against: u) } VStack(alignment: .leading, spacing: 1) { - Text( - u.outpointTxid.map { String(format: "%02x", $0) }.joined() - + ":" + "\(u.outpointVout)" - ) - .font(.caption2.monospaced()) - .lineLimit(1) - .truncationMode(.middle) + HStack(spacing: 6) { + Text( + u.outpointTxid.map { String(format: "%02x", $0) }.joined() + + ":" + "\(u.outpointVout)" + ) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + if persistedTxosByOutpoint[outpointKey] == nil { + Text("⚠︎ no row") + .font(.caption2) + .foregroundColor(.red) + } else if let kind = mismatchKind, !kind.isEmpty { + Text("⚠︎ \(kind)") + .font(.caption2) + .foregroundColor(.red) + } + } Text(formatDuffs(u.valueDuffs)) .font(.caption) .foregroundColor(.secondary) @@ -1112,6 +1223,80 @@ struct AccountDrillDownView: View { } } + /// SwiftData rows the in-memory wallet doesn't claim. Surfaces + /// the cascade / spent-flag / orphan-row class of bugs where + /// `load_from_persistor` would over-restore on next launch. + @ViewBuilder + private var orphanPersistedSection: some View { + if !orphanPersistedTxos.isEmpty { + Section { + Text( + "These PersistentTxo rows are unspent on disk but " + + "the in-memory wallet doesn't list them. They " + + "would surface on the next load_from_persistor " + + "and inflate the restored balance." + ) + .font(.caption2) + .foregroundColor(.secondary) + ForEach(Array(orphanPersistedTxos.enumerated()), id: \.offset) { _, p in + DisclosureGroup { + KVRow(label: "Amount", value: formatDuffs(p.amount)) + KVRow(label: "Height", value: "\(p.height)") + KVRow(label: "isConfirmed", value: p.isConfirmed ? "yes" : "no") + KVRow(label: "isCoinbase", value: p.isCoinbase ? "yes" : "no") + KVRow(label: "isInstantLocked", value: p.isInstantLocked ? "yes" : "no") + KVRow(label: "Address", value: p.address.isEmpty ? "(none)" : p.address) + KVRow( + label: "wallet match", + value: p.walletIdMatches ? "yes" : "no", + valueColor: p.walletIdMatches ? nil : .red + ) + KVRow( + label: "account linked", + value: p.hasAccountLink ? "yes" : "no", + valueColor: p.hasAccountLink ? nil : .orange + ) + KVRow( + label: "coreAddress linked", + value: p.hasCoreAddressLink ? "yes" : "no", + valueColor: p.hasCoreAddressLink ? nil : .orange + ) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(p.outpointHex) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Text(formatDuffs(p.amount)) + .font(.caption) + .foregroundColor(.red) + } + } + } + } header: { + Text("Orphan persisted UTXOs (\(orphanPersistedTxos.count))") + } + } + } + + /// Compose a one-word summary of the most-prominent disagreement + /// between a `TxoSnapshot` and the in-memory `AccountUtxo`. The + /// label badge shows this so reviewers can scan a long list + /// without expanding every row. Returns an empty string when + /// every checked field agrees. + private func mismatchSummary( + persisted p: TxoSnapshot, + against m: PlatformWalletManager.AccountUtxo + ) -> String { + if p.isSpent { return "spent on disk" } + if !p.walletIdMatches { return "wallet id mismatch" } + if p.amount != m.valueDuffs { return "amount mismatch" } + if p.height != m.height { return "height mismatch" } + if !p.hasAccountLink { return "no account link" } + if !p.hasCoreAddressLink { return "no coreAddress link" } + return "" + } + private func load() { metadata = walletManager.accountMetadata(for: walletId, balance: balance) pools = walletManager.accountAddressPools(for: walletId, balance: balance) @@ -1119,6 +1304,98 @@ struct AccountDrillDownView: View { // Tx history is event-driven and not held in memory; skip the // accessor here — see the comment on the body's omitted // `transactionsSection`. + + // Refresh the SwiftData side. Fetch every unspent + // `PersistentTxo` for this wallet, then narrow to rows + // routed to this account by tag tuple — `PersistentTxo` + // links to `PersistentAccount` directly (line 85 in the + // model), so we filter on `account.accountType / + // accountIndex / standardTag / registrationIndex / keyClass` + // in Swift (SwiftData `#Predicate` doesn't traverse + // `account?.…` nicely). Result is keyed by 36-byte outpoint + // for the per-row comparison loop. + let walletIdLocal = walletId + let typeTag = balance.typeTag + let standardTag = balance.standardTag + let accountIdx = balance.index + let regIdx = balance.registrationIndex + let keyClass = balance.keyClass + let descriptor = FetchDescriptor( + predicate: #Predicate { txo in + txo.walletId == walletIdLocal && txo.isSpent == false + } + ) + let rows = (try? modelContext.fetch(descriptor)) ?? [] + var byOutpoint: [Data: TxoSnapshot] = [:] + var orphanCandidates: [TxoSnapshot] = [] + let inMemoryOutpoints: Set = Set(utxos.map { u in + PersistentTxo.makeOutpoint(txid: u.outpointTxid, vout: u.outpointVout) + }) + for row in rows { + guard let acc = row.account else { + // No account link — definitely orphan, surface in + // the orphan section regardless of tag matching. + let snap = TxoSnapshot(from: row, expectedWalletId: walletIdLocal) + orphanCandidates.append(snap) + continue + } + // Filter on the same account tag tuple the manager uses + // when routing in-memory UTXOs into accounts. A row that + // doesn't match this account — even if its walletId + // matches — belongs to a sibling account in this view's + // sibling drill-downs. + let matchesThisAccount = + UInt8(exactly: acc.accountType) == typeTag + && acc.standardTag == standardTag + && acc.accountIndex == accountIdx + && acc.registrationIndex == regIdx + && acc.keyClass == keyClass + guard matchesThisAccount else { continue } + let snap = TxoSnapshot(from: row, expectedWalletId: walletIdLocal) + byOutpoint[row.outpoint] = snap + if !inMemoryOutpoints.contains(row.outpoint) { + orphanCandidates.append(snap) + } + } + persistedTxosByOutpoint = byOutpoint + orphanPersistedTxos = orphanCandidates + } +} + +/// Plain-Swift snapshot of the `PersistentTxo` fields the explorer +/// reads. Decouples the view from the SwiftData @Model so we don't +/// hand a managed object across `@State` (which fights with +/// SwiftUI's value-semantics expectations) and so the comparison +/// helpers don't have to walk the relationship graph mid-render. +private struct TxoSnapshot: Equatable { + let outpoint: Data + let outpointHex: String + let amount: UInt64 + let height: UInt32 + let isConfirmed: Bool + let isCoinbase: Bool + let isInstantLocked: Bool + let isLocked: Bool + let isSpent: Bool + let address: String + let walletIdMatches: Bool + let hasAccountLink: Bool + let hasCoreAddressLink: Bool + + init(from row: PersistentTxo, expectedWalletId: Data) { + self.outpoint = row.outpoint + self.outpointHex = row.outpointHex + self.amount = row.amount + self.height = row.height + self.isConfirmed = row.isConfirmed + self.isCoinbase = row.isCoinbase + self.isInstantLocked = row.isInstantLocked + self.isLocked = row.isLocked + self.isSpent = row.isSpent + self.address = row.address + self.walletIdMatches = row.walletId == expectedWalletId + self.hasAccountLink = row.account != nil + self.hasCoreAddressLink = row.coreAddress != nil } }