From f0fb23cadb4a22f68aa8ae564982330b95914fc2 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 6 Dec 2025 09:35:19 +0000 Subject: [PATCH 1/6] feat(cdk): add configurable V1/V2 keyset ID dual exposure Keysets now expose both V1 and V2 IDs in API responses to support backward compatibility during the keyset ID version migration. V2 keysets can optionally expose their V1 equivalent ID, controlled by the expose_v1_keyset_ids configuration option (default: true). - Add compute_alternate_id to derive V1 <-> V2 IDs - Extend pubkeys and keysets endpoints to return both ID versions - Add keyset lookup by alternate ID for proof verification - Add expose_v1_keyset_ids config option and env var support --- .../src/bin/start_regtest_mints.rs | 1 + crates/cdk-integration-tests/src/shared.rs | 3 + crates/cdk-mintd/src/config.rs | 11 ++ crates/cdk-mintd/src/env_vars/common.rs | 1 + crates/cdk-mintd/src/env_vars/info.rs | 7 + crates/cdk-mintd/src/lib.rs | 3 +- crates/cdk-signatory/src/db_signatory.rs | 46 ++++- crates/cdk/src/mint/builder.rs | 12 ++ crates/cdk/src/mint/keysets/mod.rs | 163 +++++++++++++++--- crates/cdk/src/mint/mod.rs | 22 ++- 10 files changed, 234 insertions(+), 35 deletions(-) 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/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index d446ba2b0..d6dd39835 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::>(), }) } 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..4bf2b8e1b 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,6 +13,38 @@ use crate::Error; mod auth; impl Mint { + /// Compute the alternate keyset ID (V1 <-> V2) + 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 the active keyset for distribution to wallet /// clients #[instrument(skip(self))] @@ -28,45 +63,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..f2de056ea 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)), }) } @@ -1022,9 +1032,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(), + true, + ) + .await + .unwrap() } #[tokio::test] From de830d7c60cb2802a053c56e28a2e0fc17ed81c2 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 6 Dec 2025 11:40:35 +0000 Subject: [PATCH 2/6] feat(cdk): generate V2 native keysets and add dual ID tests - Change new keyset generation to use V2 (Version01) instead of V1 - Preserve original version when reloading existing keysets from DB - Add comprehensive tests for dual ID exposure: - pubkeys() and keysets() return both V1/V2 IDs when enabled - V1 exposure can be disabled to show only V2 IDs - Runtime toggle via set_v1_id_exposure() - Keyset lookup works with both IDs for backward compatibility - Rotated keysets also get dual IDs - Update existing tests for V2 native keyset behavior --- crates/cdk-signatory/src/common.rs | 9 +- crates/cdk/src/mint/mod.rs | 570 +++++++++++++++++++++++++++-- 2 files changed, 555 insertions(+), 24 deletions(-) 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/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index f2de056ea..a9038b6ca 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -1007,6 +1007,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, @@ -1037,7 +1044,7 @@ mod tests { signatory, localstore, HashMap::new(), - true, + expose_v1_keyset_ids, ) .await .unwrap() @@ -1053,22 +1060,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" ); } @@ -1084,6 +1088,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 @@ -1093,12 +1103,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" + ); } } } @@ -1121,9 +1152,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"); + + // 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" + ); - assert_eq!(expected_keys, serde_json::to_string(&keys.clone()).unwrap()); + // 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] @@ -1152,4 +1208,476 @@ 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" + ); + } } From 8f0a0ae18fc1abc283fccc5ef31460c82a9913d8 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 6 Dec 2025 12:11:51 +0000 Subject: [PATCH 3/6] feat(wallet): prefer V2 keyset IDs when selecting active keyset When selecting the active keyset for new proofs, the wallet now prefers V2 (Version01) keysets over V1 (Version00). Among keysets of the same version, the one with the lowest fees is selected. This ensures that when a mint exposes both V1 and V2 keyset IDs for backward compatibility, the wallet will create new proofs using the V2 keyset ID, supporting the migration path to V2-only keysets. Selection priority: 1. V2 keysets (higher version byte) preferred 2. Lowest input_fee_ppk among same version Changes: - Update fetch_active_keyset() and get_active_keyset() to use min_by_key with (Reverse(version), fee) tuple comparison - Add 10 unit tests covering V2 preference scenarios --- crates/cdk/src/wallet/keysets.rs | 218 +++++++++++++++++++++++++++++-- 1 file changed, 209 insertions(+), 9 deletions(-) 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()); + } +} From 714c0da0266a3dc54508eccb8d21b391577bba7b Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 6 Dec 2025 13:32:01 +0000 Subject: [PATCH 4/6] fix: v1 look up --- crates/cdk/src/mint/keysets/mod.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index 4bf2b8e1b..581e469f3 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -45,17 +45,32 @@ impl Mint { ); } - /// Retrieve the public keys of the active keyset for distribution to wallet - /// clients + /// 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()], }) } From 3f5ba854259a921c0a3bd54d3887e2e5528ce5ea Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 14 Dec 2025 13:09:34 +0000 Subject: [PATCH 5/6] feat: loopup test --- crates/cdk-signatory/src/db_signatory.rs | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/cdk-signatory/src/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index d6dd39835..f264ab363 100644 --- a/crates/cdk-signatory/src/db_signatory.rs +++ b/crates/cdk-signatory/src/db_signatory.rs @@ -298,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(); From 934ef26ddae3d9e5e304ea81a99960f9cac8c1ff Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 14 Dec 2025 13:46:23 +0000 Subject: [PATCH 6/6] fix: get keyset for alt id --- crates/cdk/src/mint/keysets/mod.rs | 2 +- crates/cdk/src/mint/mod.rs | 101 +++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index 581e469f3..09070a580 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -14,7 +14,7 @@ mod auth; impl Mint { /// Compute the alternate keyset ID (V1 <-> V2) - fn compute_alternate_id(&self, keyset: &SignatoryKeySet) -> Option { + pub(crate) fn compute_alternate_id(&self, keyset: &SignatoryKeySet) -> Option { match keyset.id.get_version() { KeySetVersion::Version00 => { // Current is V1, compute V2 diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index a9038b6ca..94092760a 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -830,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 @@ -1680,4 +1688,83 @@ mod tests { "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" + ); + } }