diff --git a/key-wallet/src/tests/edge_case_tests.rs b/key-wallet/src/tests/edge_case_tests.rs index 28e8811c8..d99a71183 100644 --- a/key-wallet/src/tests/edge_case_tests.rs +++ b/key-wallet/src/tests/edge_case_tests.rs @@ -97,10 +97,11 @@ fn test_network_mismatch_handling() { ) .unwrap(); - // Wallet IDs should be the same (derived from same root key) - assert_eq!(testnet_wallet.wallet_id, mainnet_wallet.wallet_id); + // Wallet IDs are network-scoped, so the same mnemonic yields different ids + // on different networks. + assert_ne!(testnet_wallet.wallet_id, mainnet_wallet.wallet_id); - // But networks should be different + // And networks should be different assert_eq!(testnet_wallet.network, Network::Testnet); assert_eq!(mainnet_wallet.network, Network::Mainnet); } diff --git a/key-wallet/src/tests/integration_tests.rs b/key-wallet/src/tests/integration_tests.rs index 19f280c73..ebe235ab1 100644 --- a/key-wallet/src/tests/integration_tests.rs +++ b/key-wallet/src/tests/integration_tests.rs @@ -115,9 +115,11 @@ fn test_separate_wallets_per_network() { assert_eq!(devnet_wallet.network, Network::Devnet); assert_eq!(devnet_wallet.accounts.standard_bip44_accounts.len(), 2); - // All share the same wallet_id - assert_eq!(testnet_wallet.wallet_id, mainnet_wallet.wallet_id); - assert_eq!(testnet_wallet.wallet_id, devnet_wallet.wallet_id); + // The wallet id is network-scoped, so the same mnemonic yields a distinct + // wallet_id on each network. + assert_ne!(testnet_wallet.wallet_id, mainnet_wallet.wallet_id); + assert_ne!(testnet_wallet.wallet_id, devnet_wallet.wallet_id); + assert_ne!(mainnet_wallet.wallet_id, devnet_wallet.wallet_id); } #[test] diff --git a/key-wallet/src/tests/wallet_tests.rs b/key-wallet/src/tests/wallet_tests.rs index f165030fa..0b525aebd 100644 --- a/key-wallet/src/tests/wallet_tests.rs +++ b/key-wallet/src/tests/wallet_tests.rs @@ -143,7 +143,8 @@ fn test_wallet_creation_watch_only() { // But the wallet id must still equal the hash of the source root xpub // (`from_xpub` derives the id from the xpub at construction time). - let expected_id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key); + let expected_id = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key, Some(Network::Testnet)); assert_eq!(wallet.wallet_id, expected_id); assert_eq!(wallet.compute_wallet_id(), expected_id); } @@ -155,10 +156,12 @@ fn test_wallet_id_computation() { let root_priv_key = RootExtendedPrivKey::new_master(&seed).unwrap(); let root_pub_key = root_priv_key.to_root_extended_pub_key(); - let wallet_id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key); + let wallet_id = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key, Some(Network::Testnet)); // Wallet ID should be deterministic - let wallet_id_2 = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key); + let wallet_id_2 = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key, Some(Network::Testnet)); assert_eq!(wallet_id, wallet_id_2); // Create wallet and verify ID matches @@ -371,7 +374,8 @@ fn test_wallet_external_signable() { // of the root xpub fed into `from_external_signable`. assert!(matches!(wallet.wallet_type, WalletType::ExternalSignable)); assert!(wallet.root_extended_pub_key().is_err()); - let expected_id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key); + let expected_id = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root_pub_key, Some(Network::Testnet)); assert_eq!(wallet.wallet_id, expected_id); assert_eq!(wallet.compute_wallet_id(), expected_id); } diff --git a/key-wallet/src/wallet/initialization.rs b/key-wallet/src/wallet/initialization.rs index c0fd7cd6e..1962fd473 100644 --- a/key-wallet/src/wallet/initialization.rs +++ b/key-wallet/src/wallet/initialization.rs @@ -153,7 +153,8 @@ impl Wallet { ); } }; - let wallet_id = Self::compute_wallet_id_from_root_extended_pub_key(&root_pub_key); + let wallet_id = + Self::compute_wallet_id_from_root_extended_pub_key(&root_pub_key, Some(network)); Self { network, @@ -263,8 +264,10 @@ impl Wallet { can_sign_externally: bool, ) -> Result { let root_extended_public_key = RootExtendedPubKey::from_extended_pub_key(&master_xpub); - let wallet_id = - Self::compute_wallet_id_from_root_extended_pub_key(&root_extended_public_key); + let wallet_id = Self::compute_wallet_id_from_root_extended_pub_key( + &root_extended_public_key, + Some(master_xpub.network), + ); let wallet = if can_sign_externally { Self::new_external_signable(master_xpub.network, wallet_id, accounts) } else { @@ -288,8 +291,10 @@ impl Wallet { accounts: AccountCollection, ) -> Result { let root_extended_public_key = RootExtendedPubKey::from_extended_pub_key(&master_xpub); - let wallet_id = - Self::compute_wallet_id_from_root_extended_pub_key(&root_extended_public_key); + let wallet_id = Self::compute_wallet_id_from_root_extended_pub_key( + &root_extended_public_key, + Some(master_xpub.network), + ); Ok(Self::new_external_signable(master_xpub.network, wallet_id, accounts)) } diff --git a/key-wallet/src/wallet/mod.rs b/key-wallet/src/wallet/mod.rs index 4b8e1adbb..43f5001b0 100644 --- a/key-wallet/src/wallet/mod.rs +++ b/key-wallet/src/wallet/mod.rs @@ -95,20 +95,57 @@ pub struct WalletScanResult { } impl Wallet { - /// Compute wallet ID from root public key + /// Compute a wallet ID from a root public key. + /// + /// `network` controls scoping: + /// + /// * `Some(network)` → the **network-scoped id** (the default; this is what + /// wallet construction stamps into [`Wallet::wallet_id`]). It folds an + /// explicit network discriminant into the hash, so the same seed maps to + /// distinct ids per network. The preimage is: + /// + /// ```text + /// root_public_key.serialize() || root_chain_code || DOMAIN_TAG || network_byte + /// ``` + /// + /// where `DOMAIN_TAG` is the private `NETWORK_SCOPED_WALLET_ID_DOMAIN` + /// constant and `network_byte` comes from the private + /// `network_scoped_wallet_id_discriminant` mapping (wire-stable, *not* + /// `Network as u8`). The tag guarantees a `Some(_)` digest can never collide + /// with the `None` digest. + /// * `None` → a **network-independent id**. The preimage is exactly + /// `root_public_key.serialize() || root_chain_code` (no tag, no discriminant) + /// — useful when a caller deliberately wants one id shared across networks. + /// + /// Callers comparing a `Some(network)` id against a `None` id (or against a + /// `Some(other_network)` id) for the same key must **not** expect equality — + /// those are intentionally different digests. pub fn compute_wallet_id_from_root_extended_pub_key( root_pub_key: &RootExtendedPubKey, + network: Option, ) -> [u8; 32] { let mut data = Vec::new(); data.extend_from_slice(&root_pub_key.root_public_key.serialize()); data.extend_from_slice(&root_pub_key.root_chain_code[..]); + // A concrete network appends the domain tag + discriminant byte; with no + // network the preimage stops here, giving a network-independent digest. + if let Some(network) = network { + data.extend_from_slice(Self::NETWORK_SCOPED_WALLET_ID_DOMAIN); + data.push(Self::network_scoped_wallet_id_discriminant(network)); + } // Compute SHA256 hash let hash = sha256::Hash::hash(&data); hash.to_byte_array() } - /// Compute wallet ID. + /// Compute this wallet's ID. + /// + /// The id is **network-scoped**: it folds `self.network` into the digest via + /// [`Wallet::compute_wallet_id_from_root_extended_pub_key`]`(.., Some(self.network))`, + /// so the same seed yields distinct ids on different networks. This is what + /// construction stamps into `self.wallet_id`, so for full wallets the two + /// agree. /// /// For wallet types that carry a root public key (directly or derivable from a /// stored root private key), this recomputes the id from that key. For the @@ -122,6 +159,7 @@ impl Wallet { &self .root_extended_pub_key_cow() .expect("signing wallet types always have a root public key"), + Some(self.network), ), } } @@ -129,6 +167,41 @@ impl Wallet { pub fn downgrade_to_external_signable(&mut self) { self.wallet_type = WalletType::ExternalSignable; } + + /// Domain-separation tag appended (with the network discriminant) when a + /// concrete network is supplied, so a network-scoped id can never collide + /// with the network-independent (`None`) digest. Not added for the `None` + /// case. + const NETWORK_SCOPED_WALLET_ID_DOMAIN: &'static [u8] = b"N"; + + /// Stable, wire-stable discriminant for a network used when deriving a + /// network-scoped wallet id. + /// + /// **These bytes are a wire-stable contract.** They are deliberately *not* + /// derived from `Network as u8`: the `#[repr(u8)]` discriminant of the + /// [`Network`] enum can drift if variants are reordered or inserted, which + /// would silently change every persisted scoped id. The mapping here is + /// fixed and must never change for an existing variant: + /// + /// | network | byte | + /// |------------|--------| + /// | `Mainnet` | `0x00` | + /// | `Testnet` | `0x01` | + /// | `Devnet` | `0x02` | + /// | `Regtest` | `0x03` | + /// + /// There is intentionally no entry for "no network": when no network is + /// supplied the digest is the network-independent id, which carries neither + /// the domain tag nor a discriminant byte. Any future [`Network`] variant must + /// be assigned a new, never-before-used byte here. + const fn network_scoped_wallet_id_discriminant(network: Network) -> u8 { + match network { + Network::Mainnet => 0x00, + Network::Testnet => 0x01, + Network::Devnet => 0x02, + Network::Regtest => 0x03, + } + } } impl fmt::Display for Wallet { @@ -536,4 +609,172 @@ mod tests { assert_eq!(wallet1.wallet_id, wallet2.wallet_id); } + + // Fixed test mnemonic used by the network-scoped wallet id tests below. + const FIXTURE_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn fixture_root_pub_key(network: Network) -> RootExtendedPubKey { + let mnemonic = Mnemonic::from_phrase(FIXTURE_MNEMONIC, Language::English).unwrap(); + let wallet = Wallet::from_mnemonic( + mnemonic, + network, + initialization::WalletAccountCreationOptions::None, + ) + .unwrap(); + wallet.root_extended_pub_key_cow().unwrap().into_owned() + } + + // (a) Same seed + different networks => different ids. The network-independent + // (`None`) digest is also distinct from every concrete-network id, so all five + // values are pairwise distinct. + #[test] + fn test_wallet_id_differs_by_network() { + // The raw root key is network-independent, so derive it once and scope it + // four different ways (plus the network-independent `None` case). + let root = fixture_root_pub_key(Network::Mainnet); + + let mainnet = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Mainnet)); + let testnet = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Testnet)); + let devnet = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Devnet)); + let regtest = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Regtest)); + let none = Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None); + + let ids = [mainnet, testnet, devnet, regtest, none]; + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!( + ids[i], ids[j], + "ids for distinct network discriminants must differ ({i} vs {j})" + ); + } + } + } + + // (b) The wallet id is network-scoped by default: the same mnemonic on + // different networks produces different `wallet_id`s, and each stamped id + // equals the explicit `Some(network)` derivation. + #[test] + fn test_wallet_id_is_network_scoped_by_default() { + let make = |network| { + let mnemonic = Mnemonic::from_phrase(FIXTURE_MNEMONIC, Language::English).unwrap(); + Wallet::from_mnemonic( + mnemonic, + network, + initialization::WalletAccountCreationOptions::None, + ) + .unwrap() + }; + + let mainnet = make(Network::Mainnet); + let testnet = make(Network::Testnet); + + // Same seed, different network => different stamped ids. + assert_ne!(mainnet.wallet_id, testnet.wallet_id); + + // Each stamped id matches the explicit Some(network) derivation, and the + // instance accessor recomputes the same value. + let root = mainnet.root_extended_pub_key_cow().unwrap(); + assert_eq!( + mainnet.wallet_id, + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Mainnet)) + ); + assert_eq!(mainnet.compute_wallet_id(), mainnet.wallet_id); + assert_eq!( + testnet.wallet_id, + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Testnet)) + ); + assert_eq!(testnet.compute_wallet_id(), testnet.wallet_id); + } + + // (c) Same seed + same network => stable id across calls and across wallets. + #[test] + fn test_wallet_id_is_stable() { + let mnemonic = Mnemonic::from_phrase(FIXTURE_MNEMONIC, Language::English).unwrap(); + let wallet = Wallet::from_mnemonic( + mnemonic, + Network::Testnet, + initialization::WalletAccountCreationOptions::None, + ) + .unwrap(); + + let first = wallet.compute_wallet_id(); + assert_eq!(first, wallet.compute_wallet_id(), "id must be stable across calls"); + + let mnemonic2 = Mnemonic::from_phrase(FIXTURE_MNEMONIC, Language::English).unwrap(); + let wallet2 = Wallet::from_mnemonic( + mnemonic2, + Network::Testnet, + initialization::WalletAccountCreationOptions::None, + ) + .unwrap(); + assert_eq!(first, wallet2.compute_wallet_id()); + } + + // (d) Known-answer tests locking the wire format so the digests can never + // silently shift. The `None` digest pins the network-independent preimage; + // the `Some(Mainnet)` digest pins the domain tag + discriminant byte that + // make up the network-scoped (default) wire-stable contract. + #[test] + fn test_wallet_id_known_answers() { + let root = fixture_root_pub_key(Network::Mainnet); + + // Known answers for the "abandon ... about" fixture mnemonic. The raw root + // pubkey + chain code are network-independent, so these values are fixed + // regardless of the network passed to from_mnemonic. + let none = Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None); + assert_eq!( + hex_lower(&none), + "93401f55c5bc17629140344a2098ebdeb204dfdf1576e87605fbc7b655c86f08", + "network-independent wallet id digest must remain byte-for-byte stable" + ); + + let mainnet = + Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(Network::Mainnet)); + assert_eq!( + hex_lower(&mainnet), + "0b91f36de2613a410303e8309b4f92a150738ae018695d2030b33e64ccea7b2e", + "network-scoped (mainnet) wallet id digest must remain byte-for-byte stable; \ + a change here means DOMAIN_TAG or a discriminant byte shifted" + ); + } + + // (e) A `Some(network)` scoped id must differ from the network-independent + // (`None`) id derived from the same key. + #[test] + fn test_scoped_id_differs_from_network_independent() { + let root = fixture_root_pub_key(Network::Mainnet); + let none = Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None); + + for network in [Network::Mainnet, Network::Testnet, Network::Devnet, Network::Regtest] { + let scoped = Wallet::compute_wallet_id_from_root_extended_pub_key(&root, Some(network)); + assert_ne!( + scoped, none, + "scoped id ({network:?}) must never collide with the network-independent id" + ); + } + } + + // (f) Keyless wallet types carry no root key, so `compute_wallet_id` returns + // the construction-time id verbatim. + #[test] + fn test_wallet_id_for_keyless_wallets_returns_stored_id() { + let stored_id = [0x42u8; 32]; + + let watch_only = + Wallet::new_watch_only(Network::Testnet, stored_id, AccountCollection::new()); + assert_eq!(watch_only.compute_wallet_id(), stored_id); + + let external = + Wallet::new_external_signable(Network::Mainnet, stored_id, AccountCollection::new()); + assert_eq!(external.compute_wallet_id(), stored_id); + } + + fn hex_lower(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } }