diff --git a/Cargo.lock b/Cargo.lock index d778bf17520..3d7b6ab6988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1132,7 +1132,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "bincode_derive", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "dash-network", ] @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "async-trait", "chrono", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "anyhow", "base64-compat", @@ -1693,12 +1693,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "dashcore-rpc-json", "hex", @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "dashcore", @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "dashcore-private", @@ -2264,7 +2264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2639,7 +2639,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" [[package]] name = "glob" @@ -3552,7 +3552,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "aes", "async-trait", @@ -3811,7 +3811,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3827,7 +3827,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "async-trait", "bincode", @@ -4304,7 +4304,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5316,7 +5316,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6038,7 +6038,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6051,7 +6051,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6110,7 +6110,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6950,7 +6950,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8352,7 +8352,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 786b2d40b36..3ab272b8827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,14 +49,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } # 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/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5a860e3bed9..e27ee9b6abb 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -177,11 +177,18 @@ pub struct PersistenceCallbacks { ), >, /// Called once per registration round with the wallet's - /// network tag + birth height. `network` uses the same - /// discriminant as `WalletRestoreEntryFFI.network` (0 = Mainnet, - /// 1 = Testnet, 2 = Devnet, 3 = Regtest). `birth_height` is the - /// best estimate of the block at which the wallet started; zero - /// means "scan from genesis / unknown". + /// network tag, network-independent group id + birth height. + /// `network` uses the same discriminant as + /// `WalletRestoreEntryFFI.network` (0 = Mainnet, 1 = Testnet, + /// 2 = Devnet, 3 = Regtest). `wallet_group_id` points to 32 + /// readable bytes (same shape as `wallet_id`) — the + /// NETWORK-INDEPENDENT id shared by every network's wallet derived + /// from the same seed, so a consumer can group a seed's + /// sibling-network rows by it (the per-network `wallet_id` differs + /// per network for the same seed). For watch-only / + /// external-signable wallets it equals `wallet_id` (a group of + /// one). `birth_height` is the best estimate of the block at which + /// the wallet started; zero means "scan from genesis / unknown". /// /// Returns 0 on success. A non-zero return flips the round's /// `success` flag to `false` so [`Self::on_changeset_end_fn`] @@ -191,6 +198,7 @@ pub struct PersistenceCallbacks { context: *mut c_void, wallet_id: *const u8, network: FFINetwork, + wallet_group_id: *const u8, birth_height: u32, ) -> i32, >, @@ -570,6 +578,7 @@ impl PlatformWalletPersistence for FFIPersister { self.callbacks.context, wallet_id.as_ptr(), meta.network.into(), + meta.wallet_group_id.as_ptr(), meta.birth_height, ) }; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 00a9e39706b..4da5cea45bb 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -794,8 +794,9 @@ impl Merge for TokenBalanceChangeSet { /// Per-wallet metadata captured at registration. Carries fields not /// derivable from the xpub alone: which network the wallet is bound -/// to and the birth-height best estimate (the SPV tip at create time; -/// 0 means "scan from genesis / unknown"). +/// to, the network-independent group id that ties a seed's per-network +/// wallets together, and the birth-height best estimate (the SPV tip +/// at create time; 0 means "scan from genesis / unknown"). /// /// The shape sits on [`PlatformWalletChangeSet`] as /// `Option` because the round emits at most one @@ -811,6 +812,17 @@ impl Merge for TokenBalanceChangeSet { pub struct WalletMetadataEntry { /// Network the wallet is bound to. pub network: Network, + /// Network-INDEPENDENT 32-byte id shared by every network's wallet + /// derived from the same seed. Computed as + /// `Wallet::compute_wallet_id_from_root_extended_pub_key(root, None)` + /// — `SHA256(root_public_key || root_chain_code)` with no network + /// byte folded in. Distinct from the per-network [`Self::network`]- + /// scoped `wallet_id` the changeset is keyed on: that id differs per + /// network for the same seed, this one is the same across all of + /// them, so consumers can group a seed's sibling-network rows by it. + /// For watch-only / external-signable wallets (which carry no root + /// key) this falls back to the scoped `wallet_id` — a group of one. + pub wallet_group_id: [u8; 32], /// Best estimate of the chain tip at creation time. `0` means /// "scan from genesis / unknown". pub birth_height: u32, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index ca8d5051b39..494a1257b41 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -128,6 +128,20 @@ impl PlatformWalletManager

{ wallet: Wallet, birth_height_override: Option, ) -> Result, PlatformWalletError> { + // NOTE: the wallet id is NETWORK-SCOPED by construction. + // `Wallet::from_mnemonic` / `from_seed_bytes` now stamp a + // network-scoped id (key-wallet folds a domain-tagged, + // wire-stable network byte into the digest), so the same + // mnemonic yields a DISTINCT id per network. That makes every + // downstream `walletId`-keyed structure network-correct by + // construction — no per-network disambiguation needed in the + // persistence layer, and network-blind child tables (UTXOs, + // asset locks, platform addresses) can no longer cross-feed + // between a mnemonic's per-network wallets. The watch-only + // restore path (`Wallet::new_external_signable`) reuses the + // persisted id verbatim, so it stays self-consistent across + // launches. + // Birth height resolution: explicit override wins; otherwise // fall back to SPV's confirmed header tip (default for fresh // wallets — they only need to see funding from now on); 0 if @@ -206,6 +220,20 @@ impl PlatformWalletManager

{ address_snapshots.push((account_type, vec![(pool.pool_type, infos)])); } + // Network-INDEPENDENT group id, snapshotted BEFORE `wallet` is + // moved into `insert_wallet` below. The per-network `wallet_id` + // differs per network for the same seed (see the scoping note + // above); this digest deliberately omits the network byte + // (`None`), so every network's wallet for one seed shares it and + // the persister can group a seed's sibling-network rows by it. + // Watch-only / external-signable wallets carry no root key, so + // there's nothing to hash — fall back to the scoped `wallet_id` + // (a group of one). + let wallet_group_id = wallet + .root_extended_pub_key_cow() + .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) + .unwrap_or(wallet.wallet_id); + let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), @@ -245,6 +273,7 @@ impl PlatformWalletManager

{ let mut registration_changeset = PlatformWalletChangeSet { wallet_metadata: Some(WalletMetadataEntry { network: self.sdk.network, + wallet_group_id, birth_height, }), account_registrations: account_specs @@ -439,3 +468,112 @@ impl PlatformWalletManager

{ Ok(removed) } } + +#[cfg(test)] +mod scoped_wallet_id_tests { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + + // Canonical all-`abandon` BIP-39 test vector. Deterministic, so the + // ids below are reproducible across runs. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + fn wallet_id_for(network: Network) -> [u8; 32] { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let wallet = + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("wallet construction"); + // This is the id the manager keys on (insert_wallet returns it, + // the create FFI hands it to Swift) — exercises the same + // construction path `create_wallet_from_mnemonic` uses. + wallet.wallet_id + } + + /// The network-INDEPENDENT group id `register_wallet` computes and + /// persists onto every per-network row, so the iOS Wallet Info + /// "Networks" section can group a seed's sibling-network wallets. + /// Mirrors the `register_wallet` derivation exactly. + fn wallet_group_id_for(network: Network) -> [u8; 32] { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let wallet = + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("wallet construction"); + wallet + .root_extended_pub_key_cow() + .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) + .unwrap_or(wallet.wallet_id) + } + + /// The same mnemonic must yield a DISTINCT wallet id on each network. + /// This is the property the whole per-network persistence model now + /// relies on (rust-dashcore #793: network-scoped id by default). + #[test] + fn same_mnemonic_yields_distinct_ids_per_network() { + let mainnet = wallet_id_for(Network::Mainnet); + let testnet = wallet_id_for(Network::Testnet); + let devnet = wallet_id_for(Network::Devnet); + let regtest = wallet_id_for(Network::Regtest); + + let all = [mainnet, testnet, devnet, regtest]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!( + all[i], all[j], + "wallet ids for two different networks must differ \ + (index {i} vs {j}) — scoped-id regression" + ); + } + } + } + + /// Re-deriving the same (mnemonic, network) must be stable, otherwise + /// the watch-only restore path (which reuses the persisted id) would + /// drift across launches. + #[test] + fn same_mnemonic_same_network_is_stable() { + for network in [ + Network::Mainnet, + Network::Testnet, + Network::Devnet, + Network::Regtest, + ] { + assert_eq!( + wallet_id_for(network), + wallet_id_for(network), + "wallet id must be stable across re-derivation for {network:?}" + ); + } + } + + /// The group id must be network-INDEPENDENT: the same seed yields + /// the SAME group id on every network (this is what lets the Wallet + /// Info "Networks" section discover a seed's sibling-network rows + /// now that the scoped `walletId` differs per network). It must also + /// differ from the scoped id, or grouping would collapse back into + /// the per-network id and find nothing. + #[test] + fn group_id_is_network_independent_and_differs_from_scoped_id() { + let g_main = wallet_group_id_for(Network::Mainnet); + let g_test = wallet_group_id_for(Network::Testnet); + let g_dev = wallet_group_id_for(Network::Devnet); + let g_reg = wallet_group_id_for(Network::Regtest); + + // Same seed → identical group id across every network. + assert_eq!(g_main, g_test, "group id must not depend on network"); + assert_eq!(g_main, g_dev, "group id must not depend on network"); + assert_eq!(g_main, g_reg, "group id must not depend on network"); + + // …but the group id is NOT the scoped id (else grouping siblings + // by it would degenerate to the per-network id and miss them). + assert_ne!( + g_main, + wallet_id_for(Network::Mainnet), + "group id must differ from the network-scoped id" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 4b237821a2e..ac4d8a1fc4a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -16,11 +16,34 @@ public final class PersistentWallet { /// Index `networkRaw` so per-network wallet scans (used everywhere /// from the network-scoped storage explorer to the per-network /// "is there a wallet on this chain yet" lookups) don't degrade - /// to a table scan. - #Index([\.networkRaw]) + /// to a table scan. Also index `walletGroupId` so the Wallet Info + /// "Networks" lookup — which fetches every sibling-network row for + /// a seed by its group id — stays a keyed scan. + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) - /// 32-byte wallet ID (SHA256 of root public key). - @Attribute(.unique) public var walletId: Data + /// 32-byte NETWORK-SCOPED wallet ID, and the row's primary + /// uniqueness key. Since the network-scoping change the same seed + /// yields a DISTINCT `walletId` per network (a domain-tagged network + /// byte is folded into the digest), so a wallet that exists on + /// multiple chains has one row per network, each with its own id — + /// the network is already baked into the id, so `walletId` alone is + /// globally unique (an earlier `(walletId, networkRaw)` composite + /// was a leftover from the pre-scoping model, where one seed shared + /// a single id across networks and `networkRaw` was the only + /// distinguishing column). To gather a seed's sibling-network rows, + /// group by `walletGroupId` (which is the same across networks), + /// not by this id. + public var walletId: Data + /// 32-byte NETWORK-INDEPENDENT group id shared by every network's + /// wallet derived from the same seed (Rust computes it as the + /// no-network digest of the root key). Distinct from `walletId`, + /// which is network-scoped. Used to group a seed's sibling-network + /// rows in the Wallet Info "Networks" section. Defaults to empty + /// for rows written before this column existed (pre-release, no + /// migration); consumers treat empty as "legacy — this single row + /// only". + public var walletGroupId: Data = Data() /// Network this wallet belongs to. `nil` means "not yet known" — /// the row was created by a changeset before `persistWalletMetadata` /// filled the network in. Views treat `nil` as unknown. @@ -92,6 +115,7 @@ public final class PersistentWallet { public init( walletId: Data, + walletGroupId: Data = Data(), network: Network? = nil, name: String? = nil, walletDescription: String? = nil, @@ -100,6 +124,7 @@ public final class PersistentWallet { isImported: Bool = false ) { self.walletId = walletId + self.walletGroupId = walletGroupId self.networkRaw = network?.rawValue self.name = name self.walletDescription = walletDescription @@ -140,4 +165,22 @@ extension PersistentWallet { public static func predicate(walletId: Data) -> Predicate { #Predicate { $0.walletId == walletId } } + + /// Fetch every sibling-network row for one seed by its + /// network-independent group id. See `walletGroupId`. + public static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } + + public static func predicate( + walletId: Data, + network: Network + ) -> Predicate { + let networkRaw = network.rawValue + return #Predicate { + $0.walletId == walletId && $0.networkRaw == networkRaw + } + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0540dd48d8a..507ef109981 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -619,10 +619,24 @@ public class PlatformWalletManager: ObservableObject { try persistenceHandler.deleteWalletData(walletId: walletId) - let storage = WalletStorage() - // Delete metadata first so the mnemonic remains available for retry. - try storage.deleteMetadata(for: walletId) - try storage.deleteMnemonic(for: walletId) + // The mnemonic + metadata blobs in the Keychain are keyed by + // `walletId`. With network-scoped wallet ids the same mnemonic + // maps to a DIFFERENT id per network, so a given id is owned by + // exactly one network's wallet and carries its own mnemonic + // copy — purging it can't orphan a sibling network (those live + // under their own distinct ids). The `walletRowCountAcrossNetworks + // == 0` check is therefore expected to be true right after + // `deleteWalletData` removes this id's lone row; it is retained + // as a defensive guard (and to stay correct should the id model + // ever change) so we never delete the phrase while any row for + // this exact id still exists. + let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId) + if remaining == 0 { + let storage = WalletStorage() + // Delete metadata first so the mnemonic remains available for retry. + try storage.deleteMetadata(for: walletId) + try storage.deleteMnemonic(for: walletId) + } } // MARK: - Per-wallet lookup diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a64c724dd20..750ca691de4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -401,12 +401,12 @@ public class PlatformWalletPersistenceHandler { /// stale post-deletion callbacks can't resurrect a wiped wallet. private func ensureWalletRecord(walletId: Data) -> PersistentWallet { let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) if let existing = try? backgroundContext.fetch(descriptor).first { return existing } - let record = PersistentWallet(walletId: walletId, network: nil) + let record = PersistentWallet(walletId: walletId, network: self.network) backgroundContext.insert(record) return record } @@ -415,11 +415,27 @@ public class PlatformWalletPersistenceHandler { /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) return try? backgroundContext.fetch(descriptor).first } + /// Predicate matching the `PersistentWallet` row owned by THIS + /// handler. A handler is constructed per-network, so when + /// `self.network` is set we scope to `(walletId, networkRaw)` — + /// otherwise the mainnet handler would find and overwrite the + /// devnet row (and vice versa) now that the same `walletId` can + /// have one row per network. When `self.network` is `nil` (the + /// advanced `configure(sdkPointer:network:nil)` path) we fall + /// back to walletId-only matching to preserve that behaviour. + private func walletRecordPredicate(walletId: Data) -> Predicate { + if let network = self.network { + let networkRaw = network.rawValue + return #Predicate { $0.walletId == walletId && $0.networkRaw == networkRaw } + } + return #Predicate { $0.walletId == walletId } + } + /// Look up a `PersistentWallet` to hang on /// `PersistentIdentity.wallet`. Non-creating — returns `nil` if /// no row exists (an identity may arrive before its owning @@ -429,7 +445,7 @@ public class PlatformWalletPersistenceHandler { private func fetchWalletForLink(walletId: Data?) -> PersistentWallet? { guard let walletId else { return nil } let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) return try? backgroundContext.fetch(descriptor).first } @@ -1795,9 +1811,13 @@ public class PlatformWalletPersistenceHandler { // 1. Resolve the wallet's network from SwiftData. We need it // to feed `KeyDerivation.getIdentityAuthenticationPath` // so the path chooses the right `coin_type` (mainnet vs - // testnet). + // testnet). Scope to THIS handler's network via + // `walletRecordPredicate` — the same `walletId` can now have + // a row per network, and a bare walletId-only fetch could + // resolve to a sibling network's row and derive the key on + // the wrong chain (unusable on-chain). let walletDescriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) + predicate: walletRecordPredicate(walletId: walletId) ) guard let persistentWallet = try? backgroundContext.fetch(walletDescriptor).first @@ -2566,14 +2586,26 @@ public class PlatformWalletPersistenceHandler { private var shieldedSyncStateLoadAllocations: [UnsafeRawPointer: ShieldedSyncStateLoadAllocation] = [:] - /// Set network + birth height on the `PersistentWallet` row. Fires - /// once at wallet registration with values the Rust side can - /// contribute but Swift can't easily recompute (network is on the - /// manager's SDK; birth height is SPV's confirmed tip at creation). - func persistWalletMetadata(walletId: Data, network: Network, birthHeight: UInt32) { + /// Set network, group id + birth height on the `PersistentWallet` + /// row. Fires once at wallet registration with values the Rust side + /// can contribute but Swift can't easily recompute (network is on + /// the manager's SDK; the group id is the network-independent digest + /// Rust derives from the root key; birth height is SPV's confirmed + /// tip at creation). `walletGroupId` ties this row to its + /// sibling-network rows for the same seed; it is left empty only if + /// Rust handed back no bytes. + func persistWalletMetadata( + walletId: Data, + network: Network, + walletGroupId: Data, + birthHeight: UInt32 + ) { onQueue { let wallet = ensureWalletRecord(walletId: walletId) wallet.network = network + if !walletGroupId.isEmpty { + wallet.walletGroupId = walletGroupId + } wallet.birthHeight = birthHeight wallet.lastUpdated = Date() if !self.inChangeset { try? backgroundContext.save() } @@ -2594,11 +2626,25 @@ public class PlatformWalletPersistenceHandler { } } - public func identityIdsForWallet(walletId: Data) throws -> [Data] { + /// Count `PersistentWallet` rows for `walletId` across ALL + /// networks (deliberately ignores `self.network`). The mnemonic / + /// metadata in the Keychain are shared by every network's row, so + /// `deleteWallet` consults this after wiping its own network's row + /// to decide whether the shared Keychain material can be purged. + public func walletRowCountAcrossNetworks(walletId: Data) throws -> Int { try onQueue { let descriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletId: walletId) ) + return try backgroundContext.fetchCount(descriptor) + } + } + + public func identityIdsForWallet(walletId: Data) throws -> [Data] { + try onQueue { + let descriptor = FetchDescriptor( + predicate: walletRecordPredicate(walletId: walletId) + ) guard let walletRow = try backgroundContext.fetch(descriptor).first else { return [] } @@ -2611,7 +2657,7 @@ public class PlatformWalletPersistenceHandler { try onQueue { do { let walletDescriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) + predicate: walletRecordPredicate(walletId: walletId) ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network @@ -2689,32 +2735,63 @@ public class PlatformWalletPersistenceHandler { try backgroundContext.save() } - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - for row in try backgroundContext.fetch(txoDescriptor) { - backgroundContext.delete(row) + // The txo / pending-input / asset-lock tables are keyed + // by the network-independent walletId (same mnemonic → + // same id on every network) and carry no network column, + // so their rows are shared by every network this wallet + // lives on. Only wipe them when this is the wallet's LAST + // remaining per-network row — otherwise deleting the + // wallet from one network would erase a sibling network's + // cached UTXOs / pending inputs / asset-lock state. + // (The walletRow itself, deleted below, IS network-scoped + // via `walletRecordPredicate`.) Counted before walletRow + // is removed, so `<= 1` means "this is the last one". + // Guard on `walletRow != nil`: if this handler doesn't + // own a row for `walletId` (asked to delete a wallet it + // doesn't have), a sibling network's row can still make + // the cross-network count 1 — which would wrongly read + // as "last row" and wipe the shared child tables out + // from under that other network. No owned row → never + // treat it as the last one. + let isLastNetworkRow: Bool + if walletRow != nil { + let siblingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + isLastNetworkRow = + ((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1 + } else { + isLastNetworkRow = false } - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - for row in try backgroundContext.fetch(pendingDescriptor) { - backgroundContext.delete(row) - } + if isLastNetworkRow { + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(txoDescriptor) { + backgroundContext.delete(row) + } - // `loadCachedAssetLocksOnQueue` rehydrates these rows on - // the wallet-load path back into the Rust-side - // `unused_asset_locks` map so an in-flight registration - // can resume across an app kill. Without this cleanup, - // delete-then-reimport of the same wallet would - // resurrect stale Pending / Resumable asset-lock state - // that the user thought they had wiped. - let assetLockDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - for row in try backgroundContext.fetch(assetLockDescriptor) { - backgroundContext.delete(row) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(pendingDescriptor) { + backgroundContext.delete(row) + } + + // `loadCachedAssetLocksOnQueue` rehydrates these rows on + // the wallet-load path back into the Rust-side + // `unused_asset_locks` map so an in-flight registration + // can resume across an app kill. Without this cleanup, + // delete-then-reimport of the same wallet would + // resurrect stale Pending / Resumable asset-lock state + // that the user thought they had wiped. + let assetLockDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(assetLockDescriptor) { + backgroundContext.delete(row) + } } if let walletRow = walletRow { @@ -4029,8 +4106,14 @@ public class PlatformWalletPersistenceHandler { /// `PersistentWallet` row. Returns `nil` if the wallet row /// doesn't exist or its network hasn't been resolved yet. private func walletNetwork(walletId: Data) -> Network? { + // Scope to this handler's network when one is set so a mnemonic + // that lives on multiple networks resolves to the row for THIS + // manager's network — not an arbitrary sibling row that would + // mis-stamp persisted sync state / identity / token writes and + // feed the wrong coin type into key derivation. Falls back to + // walletId-only when no network is set (legacy / no-container). let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) guard let wallet = try? backgroundContext.fetch(descriptor).first else { return nil @@ -4966,6 +5049,7 @@ private func persistWalletMetadataCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, network: FFINetwork, + walletGroupIdPtr: UnsafePointer?, birthHeight: UInt32 ) -> Int32 { guard let context = context, @@ -4976,9 +5060,11 @@ private func persistWalletMetadataCallback( .fromOpaque(context) .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) + let walletGroupId = walletGroupIdPtr.map { Data(bytes: $0, count: 32) } ?? Data() handler.persistWalletMetadata( walletId: walletId, network: Network(ffiNetwork: network), + walletGroupId: walletGroupId, birthHeight: birthHeight ) return 0 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 29b0eefd736..6697607769e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -287,15 +287,19 @@ public final class SDK: @unchecked Sendable { // that toggle is off, the Rust side picks the canonical seed // addresses for the network. // - // `quorum_url` is forwarded whenever the UserDefaults override is - // set, regardless of network — supports custom mainnet/testnet - // shards and any future deployment that needs a non-default - // endpoint. + // `quorum_url` is gated identically: applied for devnet/regtest and + // under `useDockerSetup`, but NOT for plain mainnet/testnet. The + // `platformQuorumURL` UserDefault is only ever populated by the + // devnet-only Quorum URL field in Options, so forwarding it to a + // mainnet/testnet build leaked a devnet (often http) endpoint into a + // network whose Rust provider requires https — refusing to build the + // SDK. With the gate off, mainnet/testnet use the canonical quorum + // endpoints automatically. let result: DashSDKResult let useOverrideAddresses = network == .regtest || network == .devnet || UserDefaults.standard.bool(forKey: "useDockerSetup") - let overrideQuorumURL: String? = Self.platformQuorumURL + let overrideQuorumURL: String? = useOverrideAddresses ? Self.platformQuorumURL : nil // Resolve the DAPI address list. Two paths: // diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 1142abffe49..5febd575118 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -90,7 +90,7 @@ struct ContentView: View { .tag(RootTab.wallets) // Tab 3: Identities - IdentitiesTabView() + IdentitiesTabView(network: platformState.currentNetwork) .tabItem { Label("Identities", systemImage: "person.crop.circle") } @@ -638,9 +638,11 @@ struct WalletsTabView: View { } struct IdentitiesTabView: View { + let network: Network + var body: some View { NavigationStack { - IdentitiesContentView() + IdentitiesContentView(network: network) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 874691c55ff..465c7aa0c44 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -6,6 +6,7 @@ struct CreateWalletView: View { @Environment(\.dismiss) var dismiss @Environment(\.modelContext) private var modelContext @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var platformState: AppState @State private var walletLabel: String = "" @@ -313,7 +314,6 @@ struct CreateWalletView: View { print("PIN length: \(walletPin.count)") print("Import option enabled: \(showImportOption)") - // Determine primary network to create the wallet in (SDK enforces unique wallet per mnemonic) let selectedNetworks: [Network] = [ createForMainnet ? Network.mainnet : nil, createForTestnet ? Network.testnet : nil, @@ -321,95 +321,176 @@ struct CreateWalletView: View { (createForRegtest && shouldShowRegtest) ? Network.regtest : nil, ].compactMap { $0 } - guard let platformNetwork = selectedNetworks.first else { + guard !selectedNetworks.isEmpty else { struct MissingNetwork: LocalizedError { var errorDescription: String? { "No network selected" } } throw MissingNetwork() } - // Create exactly one wallet via PlatformWalletManager. - // The Rust-side wallet creation emits - // `persistWalletMetadata` + `setWalletName`, which - // the persister callback translates into a - // `PersistentWallet` SwiftData row — no separate - // HDWallet mirror to maintain. We only have to - // patch `isImported` after-the-fact because that - // flag is UI-cosmetic and the persister doesn't - // know about it. + // Create the wallet in EVERY ticked network. Each + // network has its own `PlatformWalletManager` (the + // Rust manager is network-locked at construction and + // stamps its own network onto the wallet), so routing + // through the active manager alone would ignore the + // passed `network`. `backgroundManager(for:)` returns + // the warm cached manager for the active network and + // builds one on demand for the others. The `walletId` + // is now network-scoped — the same mnemonic produces a + // DIFFERENT id per network — so the Keychain mnemonic + + // metadata must be written under EACH freshly-created + // network's id, and `isImported` stamped on each id's + // row. try await MainActor.run { - let managed = try walletManager.createWallet( - mnemonic: mnemonicPhrase, - network: platformNetwork, - name: walletLabel - ) - // Persist the mnemonic in the iOS Keychain keyed - // by walletId so multiple wallets coexist and the - // recovery flow can enumerate all of them on - // launch. Best-effort — failure here doesn't - // block wallet creation. - let storage = WalletStorage() - do { - try storage.storeMnemonic( - mnemonicPhrase, - for: managed.walletId - ) - } catch { - SDKLogger.error( - "Failed to persist mnemonic to keychain: \(error.localizedDescription)" - ) + // Per-network results for networks the wallet was + // FRESHLY created on this pass — each carries the + // scoped `walletId` Rust returned, which is the + // Keychain key its mnemonic / metadata / `isImported` + // writes hang off of. + var createdWallets: [(network: Network, walletId: Data)] = [] + // Real (non-"already exists") failures, surfaced to + // the user so a partial create isn't reported as + // success. + var failures: [(network: Network, message: String)] = [] + for net in selectedNetworks { + do { + let mgr = try walletManagerStore.backgroundManager(for: net) + let managed = try mgr.createWallet( + mnemonic: mnemonicPhrase, + network: net, + name: walletLabel + ) + createdWallets.append((net, managed.walletId)) + } catch { + let message = error.localizedDescription + // An "already exists" throw means the wallet + // is already on this network — benign. We do + // NOT resolve the existing scoped walletId to + // re-store the mnemonic: a wallet that already + // exists on this network had its mnemonic + + // metadata stored under that scoped id at its + // original creation, so there is nothing to + // write. It is also not counted as a freshly- + // created wallet. Any other error is a genuine + // failure. + if message.range(of: "already exists", options: .caseInsensitive) != nil { + SDKLogger.error( + "Wallet already present on \(net.displayName); continuing" + ) + } else { + failures.append((net, message)) + SDKLogger.error( + "Wallet creation failed for \(net.displayName): \(message)" + ) + } + } } - // Stamp the `isImported` flag on the - // just-created PersistentWallet row. The - // persister callback runs synchronously from - // `walletManager.createWallet` via the - // background context; SwiftData's - // `autosaveEnabled = true` on that context - // propagates the row into the main context - // before this fetch runs. If the row somehow - // isn't there yet, the flag stays `false` - // (the default on `PersistentWallet`) — a - // cosmetic miss, not a correctness issue. - let walletIdMatch = managed.walletId - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletIdMatch } - ) - let row = try? modelContext.fetch(descriptor).first - if let row = row { - row.isImported = showImportOption - try? modelContext.save() + + guard !createdWallets.isEmpty else { + // No wallet was freshly created. Two cases: + if failures.isEmpty { + // Every selected network reported "already + // exists" — the wallet is present on all of + // them and its per-network mnemonic/metadata + // were stored at the original creation. + // Re-importing is a benign no-op; dismiss + // without a misleading "could not be created" + // error. + dismiss() + return + } + // At least one network had a real failure and + // none succeeded — surface the failure detail. + struct AllNetworksFailed: LocalizedError { + let detail: String + var errorDescription: String? { + "Wallet could not be created on any selected network.\n\(detail)" + } + } + let detail = failures + .map { "\($0.network.displayName): \($0.message)" } + .joined(separator: "\n") + throw AllNetworksFailed(detail: detail) } - // 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)" + + // For EACH freshly-created network, persist that + // network's scoped walletId independently: store the + // mnemonic in the iOS Keychain keyed by that id (so + // the recovery flow can enumerate it on launch), stamp + // `isImported` on its row, and mirror the wallet + // metadata under that id. Each scoped wallet is + // independently recoverable, so its metadata records + // just THAT network. All writes are best-effort — + // failures are logged, not fatal. + let storage = WalletStorage() + for created in createdWallets { + let walletId = created.walletId + + do { + try storage.storeMnemonic(mnemonicPhrase, for: walletId) + } catch { + SDKLogger.error( + "Failed to persist mnemonic to keychain for \(created.network.displayName): \(error.localizedDescription)" + ) + } + + // Stamp `isImported` on the per-network row for + // this scoped walletId. The persister callbacks + // run synchronously from `createWallet` via the + // background contexts; autosave propagates the + // rows into the main context before this fetch. + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) ) + let rows = (try? modelContext.fetch(descriptor)) ?? [] + for row in rows { + row.isImported = showImportOption + } + if !rows.isEmpty { + try? modelContext.save() + } + + // Mirror name + birth height + just THIS network + // into the keychain alongside the mnemonic so an + // orphan-recovery after a wipe restores the + // original label / network / birth height for this + // scoped wallet. + do { + let metadata = WalletKeychainMetadata( + name: walletLabel, + walletDescription: nil, + networks: [created.network.networkName], + birthHeight: rows.first?.birthHeight + ) + try storage.setMetadata(metadata, for: walletId) + } catch { + SDKLogger.error( + "Failed to persist wallet metadata to keychain for \(created.network.displayName): \(error.localizedDescription)" + ) + } } + + // If some (but not all) networks failed, the wallet + // exists — but the user must know it wasn't added + // everywhere they ticked. Surface the partial + // failure instead of silently dismissing as success. + if !failures.isEmpty { + struct PartialCreate: LocalizedError { + let detail: String + var errorDescription: String? { + "Wallet created, but not on every selected network:\n\(detail)" + } + } + let detail = failures + .map { "\($0.network.displayName): \($0.message)" } + .joined(separator: "\n") + throw PartialCreate(detail: detail) + } + dismiss() } - print("=== WALLET CREATION SUCCESS - Created 1 wallet for \(platformNetwork.displayName) ===") + print("=== WALLET CREATION SUCCESS - networks: \(selectedNetworks.map { $0.displayName }) ===") } catch { print("=== WALLET CREATION ERROR ===") print("Error: \(error)") @@ -417,6 +498,11 @@ struct CreateWalletView: View { await MainActor.run { self.error = error isCreating = false + // Pop the pushed `SeedBackupView` so the error alert + // (bound to this view) is actually visible — otherwise + // the backup screen sits on top with its submit button + // stuck disabled and no feedback. + showBackupScreen = false } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 11829df4b6d..f6de6a47fa3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -13,8 +13,12 @@ struct IdentitiesContentView: View { @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext - @Query(sort: \PersistentIdentity.identityIndex) - private var identities: [PersistentIdentity] + /// Active network the parent threads in. Scopes the identities + /// query below so rows from another network don't leak into the + /// list after a network switch — same pattern as + /// `ContractsTabView`. + private let network: Network + @Query private var identities: [PersistentIdentity] /// All tracked asset locks across wallets. Filtered into /// "resumable" rows (status >= `InstantSendLocked` AND no /// `PersistentIdentity` at the same `(walletId, identityIndex)` @@ -41,6 +45,15 @@ struct IdentitiesContentView: View { /// when the sheet dismisses (SwiftUI nils the binding for us). @State private var resumingAssetLock: PersistentAssetLock? + init(network: Network) { + self.network = network + _identities = Query( + filter: PersistentIdentity.predicate(network: network), + sort: \PersistentIdentity.identityIndex, + order: .forward + ) + } + var body: some View { List { pendingRegistrationsSection @@ -181,6 +194,7 @@ struct IdentitiesContentView: View { } .sheet(isPresented: $showingSearchWallets) { SearchWalletsForIdentitiesView() + .environmentObject(platformState) } .refreshable { await platformBalanceSyncService.performSync() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 5339b4e1a9e..d2e294bb2a2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -229,6 +229,7 @@ struct WalletInfoView: View { @Environment(\.dismiss) var dismiss @Environment(\.modelContext) var modelContext @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore let wallet: PersistentWallet var onWalletDeleted: () -> Void = {} @@ -628,16 +629,30 @@ struct WalletInfoView: View { } private func loadNetworkStates() { - switch wallet.network ?? .testnet { - case .mainnet: - mainnetEnabled = true - case .testnet: - testnetEnabled = true - case .regtest: - regtestEnabled = true - case .devnet: - devnetEnabled = true + // A wallet now has one `PersistentWallet` row per network it + // lives on. Since network-scoping, those rows have DISTINCT + // `walletId`s (the network byte is folded into the digest), so + // they can't be matched by `walletId` anymore. They share a + // network-independent `walletGroupId` instead — group by that + // to reflect the actual set of rows rather than the single + // `wallet.network` this view was opened with. + let groupId = wallet.walletGroupId + let rows: [PersistentWallet] + if groupId.isEmpty { + // Legacy row written before the group-id column existed — + // fall back to this single row. + rows = [wallet] + } else { + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletGroupId: groupId) + ) + rows = (try? modelContext.fetch(descriptor)) ?? [wallet] } + let networks = Set(rows.compactMap { $0.network }) + mainnetEnabled = networks.contains(.mainnet) + testnetEnabled = networks.contains(.testnet) + regtestEnabled = networks.contains(.regtest) + devnetEnabled = networks.contains(.devnet) } private func loadAccountCounts() { @@ -715,18 +730,86 @@ struct WalletInfoView: View { isUpdatingNetworks = true defer { isUpdatingNetworks = false } - // TODO(platform-wallet): Proper multi-network wallet support once the - // Rust side exposes add-network. For now we only refresh UI state. + // Add the existing wallet to another network by re-creating it + // from the stored mnemonic in that network's manager. The + // `walletId` is now network-scoped — the same mnemonic produces + // a DIFFERENT id on the target network — so the freshly-created + // wallet gets its OWN scoped id, and its mnemonic must be stored + // under that new id (the source wallet's keychain entry is keyed + // by the source network's id and won't be found when the new + // network's wallet looks itself up). Reusing + // `createWallet(mnemonic:)` keeps all derivation on the Rust side + // (no Swift orchestration); the keychain write below is the + // sanctioned Swift-owned persist step. + let mnemonic: String do { - try modelContext.save() - loadNetworkStates() - loadAccountCounts() + mnemonic = try WalletStorage().retrieveMnemonic(for: wallet.walletId) } catch { - await MainActor.run { - errorMessage = "Failed to enable network: \(error.localizedDescription)" + errorMessage = "This wallet's recovery phrase isn't stored on this device, so it can't be added to another network." + showError = true + return + } + + do { + let mgr = try walletManagerStore.backgroundManager(for: network) + let created = try mgr.createWallet( + mnemonic: mnemonic, + network: network, + name: wallet.name ?? wallet.label + ) + // Persist the mnemonic AND the per-wallet metadata under the + // newly-enabled network's scoped walletId so that wallet is + // independently recoverable and its own keychain lookups + // resolve. The metadata is load-bearing for orphan-recovery + // and the post-launch warmup: `ContentView.recoverWallet` + // and the bootstrap pre-warm pick the restore network from + // `metadata.resolvedNetworks`, so without it a wiped wallet + // falls back to whatever network is active and could be + // recreated on the wrong chain. Mirror the same blob shape + // `CreateWalletView` writes per network. Best-effort — a + // failure here doesn't undo the successful create. + let storage = WalletStorage() + do { + try storage.storeMnemonic(mnemonic, for: created.walletId) + } catch { + SDKLogger.error( + "Failed to persist mnemonic to keychain for \(network.displayName): \(error.localizedDescription)" + ) + } + do { + let metadata = WalletKeychainMetadata( + name: wallet.name ?? wallet.label, + walletDescription: wallet.walletDescription, + networks: [network.networkName], + birthHeight: wallet.birthHeight + ) + try storage.setMetadata(metadata, for: created.walletId) + } catch { + SDKLogger.error( + "Failed to persist wallet metadata to keychain for \(network.displayName): \(error.localizedDescription)" + ) + } + } catch { + let description = error.localizedDescription + // An "already exists" throw means the wallet is already on + // this network — a genuine no-op, so fall through to refresh. + // Any other failure (SDK build error, Rust-side error, etc.) + // must surface to the user instead of silently doing nothing. + if description.range(of: "already exists", options: .caseInsensitive) == nil { + SDKLogger.error( + "enableNetwork(\(network.displayName)) failed: \(description)" + ) + errorMessage = "Failed to add \(network.displayName): \(description)" showError = true + return } + SDKLogger.error( + "enableNetwork(\(network.displayName)) create returned: \(description)" + ) } + + loadNetworkStates() + loadAccountCounts() } private func deleteWallet() async { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index c08b024f1af..b1463daab61 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -23,6 +23,24 @@ struct IdentityRow: View { return String(format: "%.2f DASH", dashAmount) } + private var hasAnyPrivateKey: Bool { + // A stored *PrivateKeyIdentifier only proves an identifier string + // was persisted on the row — the backing Keychain item can still + // be missing (wiped, never written, restored on another device). + // Rely solely on concrete key-presence checks against the + // Keychain so the "No Keys" badge reflects what's actually there. + let km = KeychainManager.shared + for publicKey in identity.identityPublicKeys { + if km.hasPrivateKey(identityId: identity.identityId, keyIndex: Int32(publicKey.id)) { + return true + } + if km.hasIdentityPrivateKey(publicKeyHex: publicKey.data.toHexString()) { + return true + } + } + return false + } + var body: some View { NavigationLink(destination: IdentityDetailView(identityId: identity.identityId)) { VStack(alignment: .leading, spacing: 4) { @@ -64,6 +82,23 @@ struct IdentityRow: View { .lineLimit(1) .truncationMode(.middle) + let identityType = identity.identityTypeEnum + if identityType != .user || !hasAnyPrivateKey || identity.wallet == nil { + HStack(spacing: 6) { + if identityType == .masternode { + IdentityBadge(text: "Masternode", icon: "server.rack", color: .purple) + } else if identityType == .evonode { + IdentityBadge(text: "Evonode", icon: "server.rack", color: .indigo) + } + if !hasAnyPrivateKey { + IdentityBadge(text: "No Keys", icon: "key.slash", color: .red) + } + if identity.wallet == nil { + IdentityBadge(text: "No Wallet", icon: "wallet.pass", color: .orange) + } + } + } + if identity.isLocal { HStack { Image(systemName: "location") @@ -160,3 +195,22 @@ struct IdentityRow: View { } } } + +private struct IdentityBadge: View { + let text: String + let icon: String + let color: Color + + var body: some View { + HStack(spacing: 3) { + Image(systemName: icon) + Text(text) + } + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundColor(color) + .cornerRadius(4) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index bdf78340f81..b30bdecf72c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -20,13 +20,24 @@ import SwiftData struct SearchWalletsForIdentitiesView: View { @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformState: AppState @Environment(\.dismiss) private var dismiss - /// Wallet list for the picker. Sorted by `createdAt` to match - /// the ordering `CreateIdentityView` uses, so the "first wallet" - /// that's preselected is deterministic and consistent with the - /// rest of the app. - @Query(sort: \PersistentWallet.createdAt) private var hdWallets: [PersistentWallet] + /// Every persisted wallet, across all networks. Sorted by + /// `createdAt` to match the ordering `CreateIdentityView` uses. + /// Filtered down to the active network by `hdWallets` — the + /// store keeps one row per (walletId, network), so without the + /// filter the picker would list other networks' rows too. + @Query(sort: \PersistentWallet.createdAt) private var allWallets: [PersistentWallet] + + /// Wallets on the currently-selected network — the only ones the + /// picker should offer, since the scan runs against the active + /// network's wallet manager. Mirrors the `networkRaw`-filter + /// pattern used by `WalletsContentView` / `IdentitiesContentView`. + private var hdWallets: [PersistentWallet] { + let raw = platformState.currentNetwork.rawValue + return allWallets.filter { $0.networkRaw == raw } + } /// User-selected wallet id. Initialized to the first wallet on /// appear; always preselected even when the list only has one