Skip to content
7 changes: 4 additions & 3 deletions key-wallet/src/tests/edge_case_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 5 additions & 3 deletions key-wallet/src/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 8 additions & 4 deletions key-wallet/src/tests/wallet_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
15 changes: 10 additions & 5 deletions key-wallet/src/wallet/initialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -263,8 +264,10 @@ impl Wallet {
can_sign_externally: bool,
) -> Result<Self> {
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 {
Expand All @@ -288,8 +291,10 @@ impl Wallet {
accounts: AccountCollection,
) -> Result<Self> {
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))
}

Expand Down
245 changes: 243 additions & 2 deletions key-wallet/src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Network>,
) -> [u8; 32] {
Comment thread
QuantumExplorer marked this conversation as resolved.
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
Expand All @@ -122,13 +159,49 @@ impl Wallet {
&self
.root_extended_pub_key_cow()
.expect("signing wallet types always have a root public key"),
Some(self.network),
),
}
}

pub fn downgrade_to_external_signable(&mut self) {
self.wallet_type = WalletType::ExternalSignable;
}
Comment thread
QuantumExplorer marked this conversation as resolved.

/// 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 {
Expand Down Expand Up @@ -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()
}
}
Loading