diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 154db6139..73ed97146 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -277,6 +277,7 @@ fn create_ldk_settings( input_fee_ppk: None, http_cache: cdk_axum::cache::Config::default(), enable_swagger_ui: None, + expose_v1_keyset_ids: true, logging: LoggingConfig::default(), }, mint_info: cdk_mintd::config::MintInfo::default(), diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 0c14f4377..dad63f026 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -214,6 +214,7 @@ pub fn create_fake_wallet_settings( file_level: Some("debug".to_string()), }, enable_swagger_ui: None, + expose_v1_keyset_ids: true, }, mint_info: cdk_mintd::config::MintInfo::default(), ln: cdk_mintd::config::Ln { @@ -267,6 +268,7 @@ pub fn create_cln_settings( file_level: Some("debug".to_string()), }, enable_swagger_ui: None, + expose_v1_keyset_ids: true, }, mint_info: cdk_mintd::config::MintInfo::default(), ln: cdk_mintd::config::Ln { @@ -315,6 +317,7 @@ pub fn create_lnd_settings( file_level: Some("debug".to_string()), }, enable_swagger_ui: None, + expose_v1_keyset_ids: true, }, mint_info: cdk_mintd::config::MintInfo::default(), ln: cdk_mintd::config::Ln { diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index b0d73dc46..5731eabc3 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -75,6 +75,16 @@ pub struct Info { /// If not provided, defaults are used. #[serde(skip_serializing_if = "Option::is_none")] pub quote_ttl: Option, + + /// When true, expose V1 keyset IDs alongside V2 IDs (default: true) + /// When false, only expose V2 keyset IDs in API responses + /// Note: V1 IDs will still be accepted for proof verification + #[serde(default = "default_expose_v1_ids")] + pub expose_v1_keyset_ids: bool, +} + +fn default_expose_v1_ids() -> bool { + true } impl Default for Info { @@ -92,6 +102,7 @@ impl Default for Info { enable_swagger_ui: None, logging: LoggingConfig::default(), quote_ttl: None, + expose_v1_keyset_ids: true, } } } diff --git a/crates/cdk-mintd/src/env_vars/common.rs b/crates/cdk-mintd/src/env_vars/common.rs index f00b90479..9bb546de4 100644 --- a/crates/cdk-mintd/src/env_vars/common.rs +++ b/crates/cdk-mintd/src/env_vars/common.rs @@ -21,3 +21,4 @@ pub const ENV_ENABLE_SWAGGER: &str = "CDK_MINTD_ENABLE_SWAGGER"; pub const ENV_LOGGING_OUTPUT: &str = "CDK_MINTD_LOGGING_OUTPUT"; pub const ENV_LOGGING_CONSOLE_LEVEL: &str = "CDK_MINTD_LOGGING_CONSOLE_LEVEL"; pub const ENV_LOGGING_FILE_LEVEL: &str = "CDK_MINTD_LOGGING_FILE_LEVEL"; +pub const ENV_EXPOSE_V1_KEYSET_IDS: &str = "CDK_MINTD_EXPOSE_V1_KEYSET_IDS"; diff --git a/crates/cdk-mintd/src/env_vars/info.rs b/crates/cdk-mintd/src/env_vars/info.rs index 483087624..c875de1ac 100644 --- a/crates/cdk-mintd/src/env_vars/info.rs +++ b/crates/cdk-mintd/src/env_vars/info.rs @@ -108,6 +108,13 @@ impl Info { }); } + // V1 keyset ID exposure + if let Ok(expose_str) = env::var(ENV_EXPOSE_V1_KEYSET_IDS) { + if let Ok(expose) = expose_str.parse() { + self.expose_v1_keyset_ids = expose; + } + } + self } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 56f335a38..d43b66b5e 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -375,7 +375,8 @@ fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) let mut builder = mint_builder .with_name(settings.mint_info.name.clone()) .with_version(mint_version) - .with_description(settings.mint_info.description.clone()); + .with_description(settings.mint_info.description.clone()) + .with_v1_keyset_ids(settings.info.expose_v1_keyset_ids); // Add optional information if let Some(long_description) = &settings.mint_info.description_long { diff --git a/crates/cdk-signatory/src/common.rs b/crates/cdk-signatory/src/common.rs index bae0bc045..49ac8ee44 100644 --- a/crates/cdk-signatory/src/common.rs +++ b/crates/cdk-signatory/src/common.rs @@ -63,6 +63,8 @@ pub async fn init_keysets( { tracing::debug!("Current highest index keyset matches expect fee and max order. Setting active"); let id = highest_index_keyset.id; + // Regenerate keyset using the version from the stored ID + // to maintain consistency with the database let keyset = MintKeySet::generate_from_xpriv( secp_ctx, xpriv, @@ -70,7 +72,7 @@ pub async fn init_keysets( highest_index_keyset.unit.clone(), highest_index_keyset.derivation_path.clone(), highest_index_keyset.final_expiry, - cdk_common::nut02::KeySetVersion::Version00, + highest_index_keyset.id.get_version(), ); active_keysets.insert(id, keyset); let mut keyset_info = highest_index_keyset; @@ -140,8 +142,9 @@ pub fn create_new_keyset( unit, amounts, final_expiry, - // TODO: change this to Version01 to generate keysets v2 - cdk_common::nut02::KeySetVersion::Version00, + // Generate V2 keysets - V1 IDs are exposed for backward compatibility + // via the expose_v1_keyset_ids configuration option + cdk_common::nut02::KeySetVersion::Version01, ); let keyset_info = MintKeySetInfo { id: keyset.id, diff --git a/crates/cdk-signatory/src/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index d446ba2b0..f264ab363 100644 --- a/crates/cdk-signatory/src/db_signatory.rs +++ b/crates/cdk-signatory/src/db_signatory.rs @@ -25,6 +25,8 @@ use crate::signatory::{RotateKeyArguments, Signatory, SignatoryKeySet, Signatory pub struct DbSignatory { keysets: RwLock>, active_keysets: RwLock>, + /// Track which keyset IDs are native (from DB) vs computed alternates + native_keyset_ids: RwLock>, localstore: Arc + Send + Sync>, secp_ctx: Secp256k1, custom_paths: HashMap, @@ -93,6 +95,7 @@ impl DbSignatory { let keys = Self { keysets: Default::default(), active_keysets: Default::default(), + native_keyset_ids: Default::default(), localstore, custom_paths, xpub: xpriv.to_keypair(&secp_ctx).public_key().into(), @@ -114,19 +117,45 @@ impl DbSignatory { async fn reload_keys_from_db(&self) -> Result<(), Error> { let mut keysets = self.keysets.write().await; let mut active_keysets = self.active_keysets.write().await; + let mut native_ids = self.native_keyset_ids.write().await; keysets.clear(); active_keysets.clear(); + native_ids.clear(); let db_active_keysets = self.localstore.get_active_keysets().await?; - for mut info in self.localstore.get_keyset_infos().await? { + // First, collect all native keysets from the database + let native_infos: Vec = self.localstore.get_keyset_infos().await?; + + for mut info in native_infos { let id = info.id; let keyset = self.generate_keyset(&info); info.active = db_active_keysets.get(&info.unit) == Some(&info.id); if info.active { active_keysets.insert(info.unit.clone(), id); } - keysets.insert(id, (info, keyset)); + + // Track this as a native ID + native_ids.insert(id); + + // Store with native ID + keysets.insert(id, (info.clone(), keyset.clone())); + + // Also store with alternate ID for dual-ID support (for proof verification) + use cdk_common::nut02::KeySetVersion; + use cdk_common::Keys; + let keys: Keys = keyset.keys.clone().into(); + let alternate_id = match info.id.get_version() { + KeySetVersion::Version00 => { + // Current is V1, compute V2 + Id::v2_from_data(&keys, &info.unit, info.final_expiry) + } + KeySetVersion::Version01 => { + // Current is V2, compute V1 + Id::v1_from_keys(&keys) + } + }; + keysets.insert(alternate_id, (info, keyset)); } Ok(()) @@ -203,14 +232,15 @@ impl Signatory for DbSignatory { #[tracing::instrument(skip_all)] async fn keysets(&self) -> Result { + let keysets_map = self.keysets.read().await; + let native_ids = self.native_keyset_ids.read().await; + Ok(SignatoryKeysets { pubkey: self.xpub, - keysets: self - .keysets - .read() - .await - .values() - .map(|k| k.into()) + keysets: keysets_map + .iter() + .filter(|(id, _)| native_ids.contains(id)) + .map(|(_, k)| k.into()) .collect::>(), }) } @@ -268,10 +298,79 @@ mod test { use bitcoin::key::Secp256k1; use bitcoin::Network; + use cdk_common::nut02::KeySetVersion; use cdk_common::{Amount, MintKeySet, PublicKey}; use super::*; + #[tokio::test] + async fn test_dual_id_lookup_in_signatory() { + // Create a signatory with a V2 keyset + let seed = b"test_seed_for_dual_id"; + let localstore = Arc::new(cdk_sqlite::mint::memory::empty().await.expect("create db")); + + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::Sat, (0u64, 4u8)); // Small keyset for test + + let signatory = DbSignatory::new(localstore, seed, supported_units, HashMap::new()) + .await + .expect("create signatory"); + + // Get the keysets + let keysets_response = signatory.keysets().await.expect("get keysets"); + assert_eq!(keysets_response.keysets.len(), 2); // Sat + Auth + + // Find the Sat keyset (should be V2 native) + let sat_keyset = keysets_response + .keysets + .iter() + .find(|k| k.unit == CurrencyUnit::Sat) + .expect("find sat keyset"); + + assert_eq!( + sat_keyset.id.get_version(), + KeySetVersion::Version01, + "Keyset should be V2 native" + ); + + // Compute the V1 ID from the keys + let v1_id = Id::v1_from_keys(&sat_keyset.keys); + assert_ne!(v1_id, sat_keyset.id, "V1 and V2 IDs should be different"); + + // Verify the signatory has both IDs in its keysets HashMap + { + let keysets = signatory.keysets.read().await; + assert!( + keysets.contains_key(&sat_keyset.id), + "Should have native V2 ID" + ); + assert!(keysets.contains_key(&v1_id), "Should have alternate V1 ID"); + } + + // Now test that blind_sign works with the V1 ID + use cdk_common::dhke::blind_message; + use cdk_common::secret::Secret; + + let secret = Secret::generate(); + let (blinded_message, _blinding_factor) = + blind_message(secret.as_bytes(), None).expect("blind message"); + + let blinded_msg = cdk_common::BlindedMessage { + amount: Amount::from(1), + blinded_secret: blinded_message, + keyset_id: v1_id, // Use V1 ID + witness: None, + }; + + // This should succeed because the signatory stores with both IDs + let result = signatory.blind_sign(vec![blinded_msg]).await; + assert!( + result.is_ok(), + "blind_sign should work with V1 ID: {:?}", + result.err() + ); + } + #[test] fn mint_mod_generate_keyset_from_seed() { let seed = "test_seed".as_bytes(); diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index a848c80a5..460444536 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -36,6 +36,7 @@ pub struct MintBuilder { payment_processors: HashMap, supported_units: HashMap, custom_paths: HashMap, + expose_v1_keyset_ids: bool, } impl MintBuilder { @@ -62,6 +63,7 @@ impl MintBuilder { payment_processors: HashMap::new(), supported_units: HashMap::new(), custom_paths: HashMap::new(), + expose_v1_keyset_ids: true, } } @@ -235,6 +237,14 @@ impl MintBuilder { self } + /// Configure V1 keyset ID exposure + /// - true (default): Expose both V1 and V2 IDs + /// - false: Only expose V2 IDs (sunset V1) + pub fn with_v1_keyset_ids(mut self, expose: bool) -> Self { + self.expose_v1_keyset_ids = expose; + self + } + /// Add payment processor pub async fn add_payment_processor( &mut self, @@ -325,6 +335,7 @@ impl MintBuilder { self.localstore, auth_localstore, self.payment_processors, + self.expose_v1_keyset_ids, ) .await; } @@ -333,6 +344,7 @@ impl MintBuilder { signatory, self.localstore, self.payment_processors, + self.expose_v1_keyset_ids, ) .await } diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index 1a61f42bc..09070a580 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -1,4 +1,7 @@ -use cdk_signatory::signatory::RotateKeyArguments; +use std::sync::atomic::Ordering; + +use cdk_common::nut02::KeySetVersion; +use cdk_signatory::signatory::{RotateKeyArguments, SignatoryKeySet}; use tracing::instrument; use super::{ @@ -10,17 +13,64 @@ use crate::Error; mod auth; impl Mint { - /// Retrieve the public keys of the active keyset for distribution to wallet - /// clients + /// Compute the alternate keyset ID (V1 <-> V2) + pub(crate) fn compute_alternate_id(&self, keyset: &SignatoryKeySet) -> Option { + match keyset.id.get_version() { + KeySetVersion::Version00 => { + // Current is V1, compute V2 + Some(Id::v2_from_data( + &keyset.keys, + &keyset.unit, + keyset.final_expiry, + )) + } + KeySetVersion::Version01 => { + // Current is V2, compute V1 + Some(Id::v1_from_keys(&keyset.keys)) + } + } + } + + /// Check if V1 keyset IDs should be exposed + fn should_expose_v1_ids(&self) -> bool { + self.expose_v1_keyset_ids.load(Ordering::Relaxed) + } + + /// Enable or disable V1 keyset ID exposure at runtime + pub fn set_v1_id_exposure(&self, expose: bool) { + self.expose_v1_keyset_ids.store(expose, Ordering::Relaxed); + tracing::info!( + "V1 keyset ID exposure: {}", + if expose { "enabled" } else { "disabled" } + ); + } + + /// Retrieve the public keys of a keyset by ID for distribution to wallet clients. + /// Supports lookup by both native ID and alternate ID (V1/V2) for backward compatibility. #[instrument(skip(self))] pub fn keyset_pubkeys(&self, keyset_id: &Id) -> Result { - self.keysets - .load() + let keysets = self.keysets.load(); + + // Try direct lookup first + if let Some(keyset) = keysets.iter().find(|keyset| &keyset.id == keyset_id) { + return Ok(KeysResponse { + keysets: vec![keyset.into()], + }); + } + + // If not found, try alternate ID lookup + keysets .iter() - .find(|keyset| &keyset.id == keyset_id) + .find(|keyset| { + if let Some(alternate_id) = self.compute_alternate_id(keyset) { + &alternate_id == keyset_id + } else { + false + } + }) .ok_or(Error::UnknownKeySet) - .map(|key| KeysResponse { - keysets: vec![key.into()], + .map(|keyset| KeysResponse { + keysets: vec![keyset.into()], }) } @@ -28,45 +78,127 @@ impl Mint { /// clients #[instrument(skip_all)] pub fn pubkeys(&self) -> KeysResponse { + let mut keysets_vec = Vec::new(); + let expose_v1 = self.should_expose_v1_ids(); + + for keyset in self.keysets.load().iter() { + if !keyset.active || keyset.unit == CurrencyUnit::Auth { + continue; + } + + // Always add the keyset with its native ID + keysets_vec.push(KeySet::from(keyset)); + + // Add alternate ID based on version and configuration + match keyset.id.get_version() { + KeySetVersion::Version00 => { + // Native V1 keyset - always add V2 ID + if let Some(v2_id) = self.compute_alternate_id(keyset) { + keysets_vec.push(KeySet { + id: v2_id, + unit: keyset.unit.clone(), + keys: keyset.keys.clone(), + final_expiry: keyset.final_expiry, + }); + } + } + KeySetVersion::Version01 => { + // Native V2 keyset - add V1 ID only if enabled + if expose_v1 { + if let Some(v1_id) = self.compute_alternate_id(keyset) { + keysets_vec.push(KeySet { + id: v1_id, + unit: keyset.unit.clone(), + keys: keyset.keys.clone(), + final_expiry: keyset.final_expiry, + }); + } + } + } + } + } + KeysResponse { - keysets: self - .keysets - .load() - .iter() - .filter(|keyset| keyset.active && keyset.unit != CurrencyUnit::Auth) - .map(|key| key.into()) - .collect::>(), + keysets: keysets_vec, } } /// Return a list of all supported keysets #[instrument(skip_all)] pub fn keysets(&self) -> KeysetResponse { + let mut keysets_vec = Vec::new(); + let expose_v1 = self.should_expose_v1_ids(); + + for k in self.keysets.load().iter() { + if k.unit == CurrencyUnit::Auth { + continue; + } + + // Always add with native ID + keysets_vec.push(KeySetInfo { + id: k.id, + unit: k.unit.clone(), + active: k.active, + input_fee_ppk: k.input_fee_ppk, + final_expiry: k.final_expiry, + }); + + // Add alternate ID based on version and configuration + match k.id.get_version() { + KeySetVersion::Version00 => { + // Native V1 - always add V2 ID + if let Some(v2_id) = self.compute_alternate_id(k) { + keysets_vec.push(KeySetInfo { + id: v2_id, + unit: k.unit.clone(), + active: k.active, + input_fee_ppk: k.input_fee_ppk, + final_expiry: k.final_expiry, + }); + } + } + KeySetVersion::Version01 => { + // Native V2 - add V1 ID only if enabled + if expose_v1 { + if let Some(v1_id) = self.compute_alternate_id(k) { + keysets_vec.push(KeySetInfo { + id: v1_id, + unit: k.unit.clone(), + active: k.active, + input_fee_ppk: k.input_fee_ppk, + final_expiry: k.final_expiry, + }); + } + } + } + } + } + KeysetResponse { - keysets: self - .keysets - .load() - .iter() - .filter(|k| k.unit != CurrencyUnit::Auth) - .map(|k| KeySetInfo { - id: k.id, - unit: k.unit.clone(), - active: k.active, - input_fee_ppk: k.input_fee_ppk, - final_expiry: k.final_expiry, - }) - .collect(), + keysets: keysets_vec, } } /// Get keysets #[instrument(skip(self))] pub fn keyset(&self, id: &Id) -> Option { + // Try direct lookup first + if let Some(keyset) = self.keysets.load().iter().find(|key| &key.id == id) { + return Some(keyset.into()); + } + + // If not found, try alternate ID lookup self.keysets .load() .iter() - .find(|key| &key.id == id) - .map(|x| x.into()) + .find(|keyset| { + if let Some(alternate_id) = self.compute_alternate_id(keyset) { + &alternate_id == id + } else { + false + } + }) + .map(|k| k.into()) } /// Add current keyset to inactive keysets diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 63671960e..94092760a 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -1,6 +1,7 @@ //! Cashu Mint use std::collections::HashMap; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::Duration; @@ -76,6 +77,9 @@ pub struct Mint { keysets: Arc>>, /// Background task management task_state: Arc>, + /// When true, expose V1 keyset IDs in API responses (default: true) + /// When false, only expose V2 IDs (for future V1 sunset) + expose_v1_keyset_ids: Arc, } /// State for managing background tasks @@ -94,6 +98,7 @@ impl Mint { signatory: Arc, localstore: DynMintDatabase, payment_processors: HashMap, + expose_v1_keyset_ids: bool, ) -> Result { Self::new_internal( mint_info, @@ -102,6 +107,7 @@ impl Mint { #[cfg(feature = "auth")] None, payment_processors, + expose_v1_keyset_ids, ) .await } @@ -114,6 +120,7 @@ impl Mint { localstore: DynMintDatabase, auth_localstore: DynMintAuthDatabase, payment_processors: HashMap, + expose_v1_keyset_ids: bool, ) -> Result { Self::new_internal( mint_info, @@ -121,6 +128,7 @@ impl Mint { localstore, Some(auth_localstore), payment_processors, + expose_v1_keyset_ids, ) .await } @@ -133,6 +141,7 @@ impl Mint { localstore: DynMintDatabase, #[cfg(feature = "auth")] auth_localstore: Option, payment_processors: HashMap, + expose_v1_keyset_ids: bool, ) -> Result { let keysets = signatory.keysets().await?; if !keysets @@ -219,6 +228,7 @@ impl Mint { auth_localstore, keysets: Arc::new(ArcSwap::new(keysets.keysets.into())), task_state: Arc::new(Mutex::new(TaskState::default())), + expose_v1_keyset_ids: Arc::new(AtomicBool::new(expose_v1_keyset_ids)), }) } @@ -820,18 +830,26 @@ impl Mint { } /// Get keyset info + /// Supports lookup by both native ID and alternate ID (V1/V2) for backward compatibility. pub fn get_keyset_info(&self, id: &Id) -> Option { - self.keysets - .load() + let keysets = self.keysets.load(); + + // Try direct lookup first + if let Some(keyset) = keysets.iter().find(|keyset| keyset.id == *id) { + return Some(keyset.into()); + } + + // If not found, try alternate ID lookup + keysets .iter() - .filter_map(|keyset| { - if keyset.id == *id { - Some(keyset.into()) + .find(|keyset| { + if let Some(alternate_id) = self.compute_alternate_id(keyset) { + alternate_id == *id } else { - None + false } }) - .next() + .map(|keyset| keyset.into()) } /// Blind Sign @@ -997,6 +1015,13 @@ mod tests { } async fn create_mint(config: MintConfig<'_>) -> Mint { + create_mint_with_v1_exposure(config, true).await + } + + async fn create_mint_with_v1_exposure( + config: MintConfig<'_>, + expose_v1_keyset_ids: bool, + ) -> Mint { let localstore = Arc::new( new_with_state( config.active_keysets, @@ -1022,9 +1047,15 @@ mod tests { .expect("Failed to create signatory"), ); - Mint::new(MintInfo::default(), signatory, localstore, HashMap::new()) - .await - .unwrap() + Mint::new( + MintInfo::default(), + signatory, + localstore, + HashMap::new(), + expose_v1_keyset_ids, + ) + .await + .unwrap() } #[tokio::test] @@ -1037,22 +1068,19 @@ mod tests { }; let mint = create_mint(config).await; - assert_eq!( - mint.total_issued() - .await - .unwrap() - .into_values() - .collect::>(), - vec![Amount::default()] + let total_issued = mint.total_issued().await.unwrap(); + // total_issued() returns HashMap where Id includes both V1 and V2 IDs + // With dual ID exposure, each underlying keyset appears with 2 IDs + // All values should be zero for a new mint + assert!( + total_issued.values().all(|v| *v == Amount::default()), + "All issued amounts should be zero for a new mint" ); - + // Should have 2 entries (V1 ID + V2 ID for the single keyset) assert_eq!( - mint.total_issued() - .await - .unwrap() - .into_values() - .collect::>(), - vec![Amount::default()] + total_issued.len(), + 2, + "Should have 2 keyset IDs (V1 + V2) in total_issued" ); } @@ -1068,6 +1096,12 @@ mod tests { let mint = create_mint(config).await; let keysets = mint.keysets(); + // With dual ID exposure, initial keyset appears twice (V1 + V2) + assert_eq!( + keysets.keysets.len(), + 2, + "Initial keyset should have dual IDs" + ); let first_keyset_id = keysets.keysets[0].id; // set the first keyset to inactive and generate a new keyset @@ -1077,12 +1111,33 @@ mod tests { let keysets = mint.keysets(); - assert_eq!(2, keysets.keysets.len()); + // With dual ID exposure: 2 entries for old keyset + 2 entries for new keyset = 4 + assert_eq!( + 4, + keysets.keysets.len(), + "Should have 4 keyset entries (2 keysets × 2 IDs each)" + ); + + // Count active and inactive keysets + let active_count = keysets.keysets.iter().filter(|k| k.active).count(); + let inactive_count = keysets.keysets.iter().filter(|k| !k.active).count(); + + assert_eq!( + active_count, 2, + "New keyset should have 2 active entries (V1 + V2)" + ); + assert_eq!( + inactive_count, 2, + "Old keyset should have 2 inactive entries (V1 + V2)" + ); + + // The first keyset ID should now be inactive for keyset in &keysets.keysets { if keyset.id == first_keyset_id { - assert!(!keyset.active); - } else { - assert!(keyset.active); + assert!( + !keyset.active, + "First keyset should be inactive after rotation" + ); } } } @@ -1105,9 +1160,34 @@ mod tests { let keys = mint.pubkeys(); - let expected_keys = r#"{"keysets":[{"id":"005f6e8c540c9e61","unit":"sat","keys":{"1":"03e8aded7525acee36e3394e28f2dcbc012533ef2a2b085a55fc291d311afee3ef","1024":"0351a68a667c5fc21d66c187baecefa1d65529d06b7ae13112d432b6bca16b0e8c","1048576":"02b016346e5a322d371c6e6164b28b31b4d93a51572351ca2f26cdc12e916d9ac3","1073741824":"03f12e6a0903ed0db87485a296b1dca9d953a8a6919ff88732238fbc672d6bd125","128":"0351e33a076f415c2cadc945bc9bcb75bf4a774b28df8a0605dea1557e5897fed8","131072":"027cdf7be8b20a49ac7f2f065f7c53764c8926799877858c6b00b888a8aa6741a5","134217728":"0380658e5163fcf274e1ace6c696d1feef4c6068e0d03083d676dc5ef21804f22d","16":"031dbab0e4f7fb4fb0030f0e1a1dc80668eadd0b1046df3337bb13a7b9c982d392","16384":"028e9c6ce70f34cd29aad48656bf8345bb5ba2cb4f31fdd978686c37c93f0ab411","16777216":"02f2508e7df981c32f7b0008a273e2a1f19c23bb60a1561dba6b2a95ed1251eb90","2":"02628c0919e5cb8ce9aed1f81ce313f40e1ab0b33439d5be2abc69d9bb574902e0","2048":"0376166d8dcf97d8b0e9f11867ff0dafd439c90255b36a25be01e37e14741b9c6a","2097152":"028f25283e36a11df7713934a5287267381f8304aca3c1eb1b89fddce973ef1436","2147483648":"02cece3fb38a54581e0646db4b29242b6d78e49313dda46764094f9d128c1059c1","256":"0314b9f4300367c7e64fa85770da90839d2fc2f57d63660f08bb3ebbf90ed76840","262144":"026939b8f766c3ebaf26408e7e54fc833805563e2ef14c8ee4d0435808b005ec4c","268435456":"031526f03de945c638acccb879de837ac3fabff8590057cfb8552ebcf51215f3aa","32":"037241f7ad421374eb764a48e7769b5e2473582316844fda000d6eef28eea8ffb8","32768":"0253e34bab4eec93e235c33994e01bf851d5caca4559f07d37b5a5c266de7cf840","33554432":"0381883a1517f8c9979a84fcd5f18437b1a2b0020376ecdd2e515dc8d5a157a318","4":"039e7c7f274e1e8a90c61669e961c944944e6154c0794fccf8084af90252d2848f","4096":"03d40f47b4e5c4d72f2a977fab5c66b54d945b2836eb888049b1dd9334d1d70304","4194304":"03e5841d310819a49ec42dfb24839c61f68bbfc93ac68f6dad37fd5b2d204cc535","512":"030d95abc7e881d173f4207a3349f4ee442b9e51cc461602d3eb9665b9237e8db3","524288":"03772542057493a46eed6513b40386e766eedada16560ffde2f776b65794e9f004","536870912":"035eb3e7262e126c5503e1b402db05f87de6556773ae709cb7aa1c3b0986b87566","64":"02bc9767b4abf88becdac47a59e67ee9a9a80b9864ef57d16084575273ac63c0e7","65536":"02684ede207f9ace309b796b5259fc81ef0d4492b4fb5d66cf866b0b4a6f27bec9","67108864":"02aa648d39c9a725ef5927db15af6895f0d43c17f0a31faff4406314fc80180086","8":"02ca0e563ae941700aefcb16a7fb820afbb3258ae924ab520210cb730227a76ca3","8192":"03be18afaf35a29d7bcd5dfd1936d82c1c14691a63f8aa6ece258e16b0c043049b","8388608":"0307ebfeb87b7bca9baa03fad00499e5cc999fa5179ef0b7ad4f555568bcb946f5"}}]}"#; + // With dual ID exposure, we get both V1 and V2 IDs for the same keyset + assert_eq!(keys.keysets.len(), 2, "Should have both V1 and V2 IDs"); - assert_eq!(expected_keys, serde_json::to_string(&keys.clone()).unwrap()); + // Find the V1 keyset (native ID starts with "00") + let v1_keyset = keys + .keysets + .iter() + .find(|k| k.id.to_string().starts_with("00")) + .expect("Should have V1 keyset"); + + // Verify the V1 keyset ID is deterministic + assert_eq!( + v1_keyset.id.to_string(), + "005f6e8c540c9e61", + "V1 keyset ID should be deterministic" + ); + + // Verify a sample of the keys are correct + use cdk_common::Amount; + let key_1 = v1_keyset + .keys + .get(&Amount::from(1u64)) + .expect("Should have key for amount 1"); + assert_eq!( + key_1.to_string(), + "03e8aded7525acee36e3394e28f2dcbc012533ef2a2b085a55fc291d311afee3ef", + "Key for amount 1 should be deterministic" + ); } #[tokio::test] @@ -1136,4 +1216,555 @@ mod tests { mint.start().await.expect("Should be able to restart"); mint.stop().await.expect("Final stop should work"); } + + // ==================== Dual ID Exposure Tests ==================== + + #[tokio::test] + async fn test_dual_id_exposure_pubkeys_returns_both_ids() { + // Test that pubkeys() returns both V1 and V2 IDs for a keyset + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint_with_v1_exposure(config, true).await; + + let pubkeys = mint.pubkeys(); + + // With dual ID exposure enabled, we should have 2 keysets in the response + // (one with V1 ID, one with V2 ID) for each underlying keyset + assert_eq!( + pubkeys.keysets.len(), + 2, + "Expected 2 keysets (V1 and V2 IDs) in pubkeys response" + ); + + // Both should have the same keys + assert_eq!( + pubkeys.keysets[0].keys, pubkeys.keysets[1].keys, + "Both keyset entries should have identical keys" + ); + + // IDs should be different (one V1, one V2) + assert_ne!( + pubkeys.keysets[0].id, pubkeys.keysets[1].id, + "V1 and V2 IDs should be different" + ); + + // Verify one is V1 and one is V2 + use cdk_common::nut02::KeySetVersion; + let versions: Vec<_> = pubkeys.keysets.iter().map(|k| k.id.get_version()).collect(); + assert!( + versions.contains(&KeySetVersion::Version00), + "Should contain V1 keyset ID" + ); + assert!( + versions.contains(&KeySetVersion::Version01), + "Should contain V2 keyset ID" + ); + } + + #[tokio::test] + async fn test_dual_id_exposure_keysets_returns_both_ids() { + // Test that keysets() returns both V1 and V2 IDs for a keyset + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint_with_v1_exposure(config, true).await; + + let keysets = mint.keysets(); + + // With dual ID exposure enabled, we should have 2 keyset infos + assert_eq!( + keysets.keysets.len(), + 2, + "Expected 2 keyset infos (V1 and V2 IDs) in keysets response" + ); + + // IDs should be different + assert_ne!( + keysets.keysets[0].id, keysets.keysets[1].id, + "V1 and V2 IDs should be different" + ); + + // Both should be active + assert!(keysets.keysets[0].active, "First keyset should be active"); + assert!(keysets.keysets[1].active, "Second keyset should be active"); + + // Both should have the same unit + assert_eq!( + keysets.keysets[0].unit, keysets.keysets[1].unit, + "Both should have the same unit" + ); + } + + #[tokio::test] + async fn test_keyset_lookup_by_alternate_id() { + // Test that keyset() can look up by both native and alternate ID + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint_with_v1_exposure(config, true).await; + + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 2); + + let id1 = keysets.keysets[0].id; + let id2 = keysets.keysets[1].id; + + // Both IDs should be able to look up the keyset + let keyset1 = mint.keyset(&id1); + let keyset2 = mint.keyset(&id2); + + assert!(keyset1.is_some(), "Should find keyset by first ID"); + assert!(keyset2.is_some(), "Should find keyset by second ID"); + + // Both lookups should return the same keys + assert_eq!( + keyset1.unwrap().keys, + keyset2.unwrap().keys, + "Both ID lookups should return the same keys" + ); + } + + #[tokio::test] + async fn test_v1_exposure_disabled_only_shows_v2() { + // Test that when expose_v1_keyset_ids=false, only the native V2 ID is shown. + // New keysets are created as V2 native. The V1 ID is only for backward + // compatibility and can be disabled. + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Create mint with V1 exposure disabled + let mint = create_mint_with_v1_exposure(config, false).await; + + let keysets = mint.keysets(); + + // With V1 exposure disabled, only the native V2 ID is shown + assert_eq!( + keysets.keysets.len(), + 1, + "With V1 exposure disabled, should only show V2 ID" + ); + + let pubkeys = mint.pubkeys(); + assert_eq!( + pubkeys.keysets.len(), + 1, + "With V1 exposure disabled, pubkeys should only show V2 ID" + ); + + // Verify it's V2 + use cdk_common::nut02::KeySetVersion; + assert_eq!( + keysets.keysets[0].id.get_version(), + KeySetVersion::Version01, + "The shown keyset should be V2" + ); + } + + #[tokio::test] + async fn test_runtime_toggle_v1_exposure() { + // Test that set_v1_id_exposure() toggles V1 ID visibility at runtime. + // New keysets are V2 native, so the flag controls V1 backward compatibility. + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Start with V1 exposure enabled (default for backward compatibility) + let mint = create_mint_with_v1_exposure(config, true).await; + + // Initially should have both IDs (V2 native + V1 for compatibility) + let keysets = mint.keysets(); + assert_eq!( + keysets.keysets.len(), + 2, + "With V1 exposure enabled, should show both V1 and V2 IDs" + ); + + // Disable V1 exposure at runtime + mint.set_v1_id_exposure(false); + + // Now should only show V2 ID + let keysets = mint.keysets(); + assert_eq!( + keysets.keysets.len(), + 1, + "After disabling V1 exposure, should only show V2 ID" + ); + + // Re-enable V1 exposure + mint.set_v1_id_exposure(true); + + // Should have both IDs again + let keysets = mint.keysets(); + assert_eq!( + keysets.keysets.len(), + 2, + "After re-enabling V1 exposure, should show both IDs again" + ); + } + + #[tokio::test] + async fn test_alternate_id_lookup_still_works_when_v1_disabled() { + // Test that keyset lookup by alternate ID still works even when V1 exposure is disabled + // (This is important for proof verification with old tokens) + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Create mint with V1 exposure enabled to get both IDs + let mint = create_mint_with_v1_exposure(config, true).await; + + // Get both IDs while V1 exposure is enabled + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 2); + let id1 = keysets.keysets[0].id; + let id2 = keysets.keysets[1].id; + + // Disable V1 exposure + mint.set_v1_id_exposure(false); + + // Both IDs should still work for lookup (for backward compatibility) + let keyset1 = mint.keyset(&id1); + let keyset2 = mint.keyset(&id2); + + assert!( + keyset1.is_some(), + "Should still find keyset by first ID after disabling V1 exposure" + ); + assert!( + keyset2.is_some(), + "Should still find keyset by second ID after disabling V1 exposure" + ); + } + + #[tokio::test] + async fn test_dual_id_computed_correctly() { + // Test that the V2 ID is computed correctly from V1 keyset + use cdk_common::nut02::KeySetVersion; + + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint_with_v1_exposure(config, true).await; + + let pubkeys = mint.pubkeys(); + assert_eq!(pubkeys.keysets.len(), 2); + + // Find V1 and V2 keysets + let v1_keyset = pubkeys + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version00) + .expect("Should have V1 keyset"); + let v2_keyset = pubkeys + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version01) + .expect("Should have V2 keyset"); + + // Verify V2 ID is correctly computed from the keys + let computed_v2_id = + Id::v2_from_data(&v1_keyset.keys, &v1_keyset.unit, v1_keyset.final_expiry); + assert_eq!( + v2_keyset.id, computed_v2_id, + "V2 ID should match computed value from keys" + ); + + // Verify V1 ID is correctly computed from the keys + let computed_v1_id = Id::v1_from_keys(&v2_keyset.keys); + assert_eq!( + v1_keyset.id, computed_v1_id, + "V1 ID should match computed value from keys" + ); + } + + #[tokio::test] + async fn test_rotated_keyset_also_has_dual_ids() { + // Test that a rotated keyset also gets dual IDs + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint_with_v1_exposure(config, true).await; + + // Rotate the keyset + mint.rotate_keyset(CurrencyUnit::default(), vec![1, 2, 4, 8], 1) + .await + .expect("Failed to rotate keyset"); + + let keysets = mint.keysets(); + + // Should have 4 entries: 2 for original keyset (V1+V2) and 2 for new keyset (V1+V2) + assert_eq!( + keysets.keysets.len(), + 4, + "Should have 4 keyset entries after rotation (2 keysets × 2 IDs each)" + ); + + // Count active keysets - should be 2 (V1 and V2 IDs for the new active keyset) + let active_count = keysets.keysets.iter().filter(|k| k.active).count(); + assert_eq!( + active_count, 2, + "Should have 2 active keyset entries (V1 and V2 IDs for the active keyset)" + ); + + // Count inactive keysets - should be 2 (V1 and V2 IDs for the old keyset) + let inactive_count = keysets.keysets.iter().filter(|k| !k.active).count(); + assert_eq!( + inactive_count, 2, + "Should have 2 inactive keyset entries (V1 and V2 IDs for the old keyset)" + ); + } + + #[tokio::test] + async fn test_rotate_with_v1_disabled_only_shows_v2_active() { + // Test that when rotating with V1 exposure disabled: + // - Old keyset (both V1 and V2 IDs) becomes inactive + // - New keyset only shows V2 ID as active + use cdk_common::nut02::KeySetVersion; + + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Create mint with V1 exposure DISABLED + let mint = create_mint_with_v1_exposure(config, false).await; + + // Initially should have only 1 keyset (V2 only, since V1 exposure is disabled) + let keysets = mint.keysets(); + assert_eq!( + keysets.keysets.len(), + 1, + "Initially should have only V2 keyset" + ); + assert_eq!( + keysets.keysets[0].id.get_version(), + KeySetVersion::Version01, + "Initial keyset should be V2" + ); + assert!(keysets.keysets[0].active, "Initial keyset should be active"); + + let initial_keyset_id = keysets.keysets[0].id; + + // Rotate the keyset + mint.rotate_keyset(CurrencyUnit::default(), vec![1, 2, 4, 8], 1) + .await + .expect("Failed to rotate keyset"); + + let keysets = mint.keysets(); + + // With V1 exposure disabled: + // - Old keyset: 1 entry (V2 only, inactive) + // - New keyset: 1 entry (V2 only, active) + // Total: 2 entries + assert_eq!( + keysets.keysets.len(), + 2, + "Should have 2 keyset entries (1 old V2 inactive + 1 new V2 active)" + ); + + // All should be V2 + assert!( + keysets + .keysets + .iter() + .all(|k| k.id.get_version() == KeySetVersion::Version01), + "All keysets should be V2 when V1 exposure is disabled" + ); + + // Count active keysets - should be 1 (only V2 ID for the new keyset) + let active_keysets: Vec<_> = keysets.keysets.iter().filter(|k| k.active).collect(); + assert_eq!( + active_keysets.len(), + 1, + "Should have exactly 1 active keyset (V2 only)" + ); + assert_eq!( + active_keysets[0].id.get_version(), + KeySetVersion::Version01, + "Active keyset should be V2" + ); + + // Count inactive keysets - should be 1 (only V2 ID for the old keyset) + let inactive_keysets: Vec<_> = keysets.keysets.iter().filter(|k| !k.active).collect(); + assert_eq!( + inactive_keysets.len(), + 1, + "Should have exactly 1 inactive keyset (V2 only)" + ); + assert_eq!( + inactive_keysets[0].id, initial_keyset_id, + "Inactive keyset should be the original keyset" + ); + } + + #[tokio::test] + async fn test_v1_lookup_still_works_when_v1_disabled() { + // Even when V1 exposure is disabled, proofs with V1 IDs should still verify. + // This is critical for backward compatibility with existing tokens. + use cdk_common::nut02::KeySetVersion; + + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Create mint with V1 exposure enabled to get the V1 ID + let mint = create_mint_with_v1_exposure(config, true).await; + + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 2); + + // Get both V1 and V2 IDs + let v1_id = keysets + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version00) + .expect("Should have V1") + .id; + let v2_id = keysets + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version01) + .expect("Should have V2") + .id; + + // Now disable V1 exposure + mint.set_v1_id_exposure(false); + + // V1 ID should no longer appear in keysets() + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 1); + assert_eq!(keysets.keysets[0].id, v2_id); + + // But V1 ID should STILL work for keyset lookup (for proof verification) + let keyset_by_v1 = mint.keyset(&v1_id); + assert!( + keyset_by_v1.is_some(), + "V1 ID lookup should still work for backward compatibility" + ); + + let keyset_by_v2 = mint.keyset(&v2_id); + assert!(keyset_by_v2.is_some(), "V2 ID lookup should work"); + + // Both should return the same keys + assert_eq!( + keyset_by_v1.unwrap().keys, + keyset_by_v2.unwrap().keys, + "Both ID lookups should return the same keys" + ); + } + + #[tokio::test] + async fn test_get_keyset_info_supports_alternate_id_lookup() { + // Test that get_keyset_info works with both native and alternate IDs. + // This is critical for verification during minting/swapping when wallet + // uses the V2 ID it received from the keysets endpoint. + use cdk_common::nut02::KeySetVersion; + + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + // Create mint with V1 exposure enabled to get both IDs + let mint = create_mint_with_v1_exposure(config, true).await; + + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 2); + + // Get both V1 and V2 IDs + let v1_id = keysets + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version00) + .expect("Should have V1") + .id; + let v2_id = keysets + .keysets + .iter() + .find(|k| k.id.get_version() == KeySetVersion::Version01) + .expect("Should have V2") + .id; + + // get_keyset_info should work with native ID + let info_by_native = mint.get_keyset_info(&v2_id); + assert!( + info_by_native.is_some(), + "get_keyset_info should find keyset by native V2 ID" + ); + + // get_keyset_info should also work with alternate ID + let info_by_alternate = mint.get_keyset_info(&v1_id); + assert!( + info_by_alternate.is_some(), + "get_keyset_info should find keyset by alternate V1 ID" + ); + + // Both should return the same keyset info + let info1 = info_by_native.unwrap(); + let info2 = info_by_alternate.unwrap(); + assert_eq!( + info1.unit, info2.unit, + "Both lookups should return the same unit" + ); + assert_eq!( + info1.active, info2.active, + "Both lookups should return the same active status" + ); + assert_eq!( + info1.input_fee_ppk, info2.input_fee_ppk, + "Both lookups should return the same fee" + ); + + // Test with V1 exposure disabled - should still work for verification + mint.set_v1_id_exposure(false); + + // V1 ID should no longer appear in keysets() + let keysets = mint.keysets(); + assert_eq!(keysets.keysets.len(), 1); + + // But get_keyset_info should STILL work with V1 ID (for verification) + let info_by_v1_after_disable = mint.get_keyset_info(&v1_id); + assert!( + info_by_v1_after_disable.is_some(), + "get_keyset_info should still find keyset by V1 ID after V1 exposure is disabled" + ); + } } diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs index 2feb121c1..1c4c12134 100644 --- a/crates/cdk/src/wallet/keysets.rs +++ b/crates/cdk/src/wallet/keysets.rs @@ -1,3 +1,4 @@ +use std::cmp::Reverse; use std::collections::HashMap; use cdk_common::amount::{FeeAndAmounts, KeysetFeeAndAmounts}; @@ -95,25 +96,28 @@ impl Wallet { } } - /// Get the active keyset with the lowest fees - fetches fresh data from mint + /// Get the active keyset with V2 preference and lowest fees - fetches fresh data from mint /// - /// Forces a fresh fetch of keysets from the mint and returns the active keyset - /// with the minimum input fees. Use this when you need the most up-to-date - /// keyset information for operations. + /// Forces a fresh fetch of keysets from the mint and returns the active keyset, + /// preferring V2 (Version01) keysets over V1 (Version00), then selecting by + /// minimum input fees. Use this when you need the most up-to-date keyset + /// information for operations. #[instrument(skip(self))] pub async fn fetch_active_keyset(&self) -> Result { self.get_mint_keysets() .await? .active() - .min_by_key(|k| k.input_fee_ppk) + // Prefer V2 keysets (higher version byte), then lowest fees + .min_by_key(|k| (Reverse(k.id.get_version().to_byte()), k.input_fee_ppk)) .cloned() .ok_or(Error::NoActiveKeyset) } - /// Get the active keyset with the lowest fees from cache + /// Get the active keyset with V2 preference and lowest fees from cache /// - /// Returns the active keyset with minimum input fees from the metadata cache. - /// Uses cached data if available, fetches from mint if cache not populated. + /// Returns the active keyset, preferring V2 (Version01) keysets over V1 (Version00), + /// then selecting by minimum input fees. Uses cached data if available, fetches + /// from mint if cache not populated. #[instrument(skip(self))] pub async fn get_active_keyset(&self) -> Result { self.metadata_cache @@ -124,7 +128,8 @@ impl Wallet { .await? .active_keysets .iter() - .min_by_key(|k| k.input_fee_ppk) + // Prefer V2 keysets (higher version byte), then lowest fees + .min_by_key(|k| (Reverse(k.id.get_version().to_byte()), k.input_fee_ppk)) .map(|ks| (**ks).clone()) .ok_or(Error::NoActiveKeyset) } @@ -175,3 +180,198 @@ impl Wallet { .ok_or(Error::UnknownKeySet) } } + +#[cfg(test)] +mod tests { + use std::cmp::Reverse; + + use cdk_common::nuts::nut02::{Id, KeySetInfo}; + use cdk_common::CurrencyUnit; + + /// Create a V1 keyset ID (Version00) with a unique suffix + fn v1_id(suffix: u8) -> Id { + Id::from_bytes(&[0x00, suffix, 0, 0, 0, 0, 0, 0]).unwrap() + } + + /// Create a V2 keyset ID (Version01) with a unique suffix + fn v2_id(suffix: u8) -> Id { + let mut bytes = [0u8; 33]; + bytes[0] = 0x01; // V2 version prefix + bytes[1] = suffix; + Id::from_bytes(&bytes).unwrap() + } + + /// Create a KeySetInfo with the given ID and fee + fn keyset_info(id: Id, input_fee_ppk: u64) -> KeySetInfo { + KeySetInfo { + id, + unit: CurrencyUnit::Sat, + active: true, + input_fee_ppk, + final_expiry: None, + } + } + + /// Helper to select the preferred keyset using the same logic as fetch_active_keyset + fn select_preferred_keyset(keysets: &[KeySetInfo]) -> Option<&KeySetInfo> { + keysets + .iter() + .filter(|k| k.active) + .min_by_key(|k| (Reverse(k.id.get_version().to_byte()), k.input_fee_ppk)) + } + + #[test] + fn test_v2_preferred_over_v1_same_fees() { + let v1 = keyset_info(v1_id(1), 100); + let v2 = keyset_info(v2_id(1), 100); + + let keysets = vec![v1.clone(), v2.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.id.get_version().to_byte(), + 1, + "V2 keyset should be preferred when fees are equal" + ); + } + + #[test] + fn test_v2_preferred_over_v1_even_with_higher_fees() { + let v1_low_fee = keyset_info(v1_id(1), 50); + let v2_high_fee = keyset_info(v2_id(1), 200); + + let keysets = vec![v1_low_fee.clone(), v2_high_fee.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.id.get_version().to_byte(), + 1, + "V2 keyset should be preferred even when V1 has lower fees" + ); + assert_eq!(selected.input_fee_ppk, 200); + } + + #[test] + fn test_lowest_fee_v2_selected_among_multiple_v2() { + let v2_high = keyset_info(v2_id(1), 200); + let v2_low = keyset_info(v2_id(2), 50); + let v2_mid = keyset_info(v2_id(3), 100); + + let keysets = vec![v2_high.clone(), v2_low.clone(), v2_mid.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.id.get_version().to_byte(), + 1, + "Should select a V2 keyset" + ); + assert_eq!( + selected.input_fee_ppk, 50, + "Should select V2 keyset with lowest fees" + ); + } + + #[test] + fn test_lowest_fee_v1_selected_when_no_v2() { + let v1_high = keyset_info(v1_id(1), 200); + let v1_low = keyset_info(v1_id(2), 50); + let v1_mid = keyset_info(v1_id(3), 100); + + let keysets = vec![v1_high.clone(), v1_low.clone(), v1_mid.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.id.get_version().to_byte(), + 0, + "Should select a V1 keyset when no V2 available" + ); + assert_eq!( + selected.input_fee_ppk, 50, + "Should select V1 keyset with lowest fees" + ); + } + + #[test] + fn test_mixed_keysets_v2_lowest_fee_wins() { + let v1_low = keyset_info(v1_id(1), 10); + let v1_high = keyset_info(v1_id(2), 500); + let v2_mid = keyset_info(v2_id(1), 100); + let v2_high = keyset_info(v2_id(2), 300); + + let keysets = vec![ + v1_low.clone(), + v1_high.clone(), + v2_mid.clone(), + v2_high.clone(), + ]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.id.get_version().to_byte(), + 1, + "Should select a V2 keyset" + ); + assert_eq!( + selected.input_fee_ppk, 100, + "Should select the V2 keyset with lowest fees (100), not V1 with 10" + ); + } + + #[test] + fn test_inactive_keysets_ignored() { + let v2_active = keyset_info(v2_id(1), 200); + let mut v2_inactive = keyset_info(v2_id(2), 50); + v2_inactive.active = false; + + let keysets = vec![v2_active.clone(), v2_inactive.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!( + selected.input_fee_ppk, 200, + "Should select active V2 keyset (200), ignoring inactive one (50)" + ); + } + + #[test] + fn test_single_v1_keyset() { + let v1 = keyset_info(v1_id(1), 100); + + let keysets = vec![v1.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!(selected.id.get_version().to_byte(), 0); + assert_eq!(selected.input_fee_ppk, 100); + } + + #[test] + fn test_single_v2_keyset() { + let v2 = keyset_info(v2_id(1), 100); + + let keysets = vec![v2.clone()]; + let selected = select_preferred_keyset(&keysets).unwrap(); + + assert_eq!(selected.id.get_version().to_byte(), 1); + assert_eq!(selected.input_fee_ppk, 100); + } + + #[test] + fn test_empty_keysets_returns_none() { + let keysets: Vec = vec![]; + let selected = select_preferred_keyset(&keysets); + + assert!(selected.is_none()); + } + + #[test] + fn test_all_inactive_returns_none() { + let mut v1 = keyset_info(v1_id(1), 100); + v1.active = false; + let mut v2 = keyset_info(v2_id(1), 100); + v2.active = false; + + let keysets = vec![v1, v2]; + let selected = select_preferred_keyset(&keysets); + + assert!(selected.is_none()); + } +}