Explorar el Código

feat: track cdk version in keysets (#1556)

a1denvalu3 hace 1 semana
padre
commit
93e80f3cbd

+ 230 - 8
crates/cdk-common/src/common.rs

@@ -196,6 +196,124 @@ impl Default for QuoteTTL {
     }
 }
 
+/// Mint Fee Reserve
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct FeeReserve {
+    /// Absolute expected min fee
+    pub min_fee_reserve: Amount,
+    /// Percentage expected fee
+    pub percent_fee_reserve: f32,
+}
+
+/// CDK Version
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct IssuerVersion {
+    /// Implementation name (e.g., "cdk", "nutshell")
+    pub implementation: String,
+    /// Major version
+    pub major: u16,
+    /// Minor version
+    pub minor: u16,
+    /// Patch version
+    pub patch: u16,
+}
+
+impl IssuerVersion {
+    /// Create new [`IssuerVersion`]
+    pub fn new(implementation: String, major: u16, minor: u16, patch: u16) -> Self {
+        Self {
+            implementation,
+            major,
+            minor,
+            patch,
+        }
+    }
+}
+
+impl std::fmt::Display for IssuerVersion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}/{}.{}.{}",
+            self.implementation, self.major, self.minor, self.patch
+        )
+    }
+}
+
+impl PartialOrd for IssuerVersion {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        if self.implementation != other.implementation {
+            return None;
+        }
+
+        match self.major.cmp(&other.major) {
+            std::cmp::Ordering::Equal => match self.minor.cmp(&other.minor) {
+                std::cmp::Ordering::Equal => Some(self.patch.cmp(&other.patch)),
+                other => Some(other),
+            },
+            other => Some(other),
+        }
+    }
+}
+
+impl std::str::FromStr for IssuerVersion {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (implementation, version_str) = s
+            .split_once('/')
+            .ok_or(Error::Custom(format!("Invalid version string: {}", s)))?;
+        let implementation = implementation.to_string();
+
+        let parts: Vec<&str> = version_str.splitn(3, '.').collect();
+        if parts.len() != 3 {
+            return Err(Error::Custom(format!("Invalid version string: {}", s)));
+        }
+
+        let major = parts[0]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid major version: {}", parts[0])))?;
+        let minor = parts[1]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid minor version: {}", parts[1])))?;
+
+        // Handle patch version with optional suffixes like -rc1
+        let patch_str = parts[2];
+        let patch_end = patch_str
+            .find(|c: char| !c.is_numeric())
+            .unwrap_or(patch_str.len());
+        let patch = patch_str[..patch_end]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid patch version: {}", parts[2])))?;
+
+        Ok(Self {
+            implementation,
+            major,
+            minor,
+            patch,
+        })
+    }
+}
+
+impl Serialize for IssuerVersion {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl<'de> Deserialize<'de> for IssuerVersion {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        std::str::FromStr::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::str::FromStr;
@@ -267,13 +385,117 @@ mod tests {
         assert_eq!(finalized.fee_paid(), Amount::from(1));
         assert_eq!(finalized.total_amount(), Amount::from(32));
     }
-}
 
-/// Mint Fee Reserve
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct FeeReserve {
-    /// Absolute expected min fee
-    pub min_fee_reserve: Amount,
-    /// Percentage expected fee
-    pub percent_fee_reserve: f32,
+    use super::IssuerVersion;
+
+    #[test]
+    fn test_version_parsing() {
+        // Test explicit cdk format
+        let v = IssuerVersion::from_str("cdk/1.2.3").unwrap();
+        assert_eq!(v.implementation, "cdk");
+        assert_eq!(v.major, 1);
+        assert_eq!(v.minor, 2);
+        assert_eq!(v.patch, 3);
+        assert_eq!(v.to_string(), "cdk/1.2.3");
+
+        // Test nutshell format
+        let v = IssuerVersion::from_str("nutshell/0.16.0").unwrap();
+        assert_eq!(v.implementation, "nutshell");
+        assert_eq!(v.major, 0);
+        assert_eq!(v.minor, 16);
+        assert_eq!(v.patch, 0);
+        assert_eq!(v.to_string(), "nutshell/0.16.0");
+    }
+
+    #[test]
+    fn test_version_ordering() {
+        let v1 = IssuerVersion::from_str("cdk/0.1.0").unwrap();
+        let v2 = IssuerVersion::from_str("cdk/0.1.1").unwrap();
+        let v3 = IssuerVersion::from_str("cdk/0.2.0").unwrap();
+        let v4 = IssuerVersion::from_str("cdk/1.0.0").unwrap();
+
+        assert!(v1 < v2);
+        assert!(v2 < v3);
+        assert!(v3 < v4);
+        assert!(v1 < v4);
+
+        // Test mixed implementations
+        let v_nutshell = IssuerVersion::from_str("nutshell/0.1.0").unwrap();
+        assert_eq!(v1.partial_cmp(&v_nutshell), None);
+        assert!(!(v1 < v_nutshell));
+        assert!(!(v1 > v_nutshell));
+        assert!(!(v1 == v_nutshell));
+    }
+
+    #[test]
+    fn test_version_serialization() {
+        let v = IssuerVersion::from_str("cdk/0.14.2").unwrap();
+        let json = serde_json::to_string(&v).unwrap();
+        assert_eq!(json, "\"cdk/0.14.2\"");
+
+        let v_deserialized: IssuerVersion = serde_json::from_str(&json).unwrap();
+        assert_eq!(v, v_deserialized);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_with_suffix() {
+        let version_str = "cdk/0.15.0-rc1";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_standard() {
+        let version_str = "cdk/0.15.0";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_complex_suffix() {
+        let version_str = "cdk/0.15.0-beta.1+build123";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_invalid() {
+        // Missing prefix
+        let version_str = "0.15.0";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+
+        // Invalid version format
+        let version_str = "cdk/0.15";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+
+        let version_str = "cdk/0.15.a";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_with_implementation() {
+        let version_str = "nutshell/0.16.2";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "nutshell");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 16);
+        assert_eq!(version.patch, 2);
+    }
+
+    #[test]
+    fn test_cdk_version_comparison_different_implementations() {
+        let v1 = IssuerVersion::from_str("cdk/0.15.0").unwrap();
+        let v2 = IssuerVersion::from_str("nutshell/0.15.0").unwrap();
+
+        assert_eq!(v1.partial_cmp(&v2), None);
+    }
 }

+ 11 - 0
crates/cdk-common/src/database/mint/test/keys.rs

@@ -5,6 +5,7 @@ use std::str::FromStr;
 use bitcoin::bip32::DerivationPath;
 use cashu::{CurrencyUnit, Id};
 
+use crate::common::IssuerVersion;
 use crate::database::mint::{Database, Error, KeysDatabase};
 use crate::mint::MintKeySetInfo;
 
@@ -29,6 +30,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info
@@ -44,6 +46,7 @@ where
     assert_eq!(retrieved.unit, keyset_info.unit);
     assert_eq!(retrieved.active, keyset_info.active);
     assert_eq!(retrieved.amounts, keyset_info.amounts);
+    assert_eq!(retrieved.issuer_version, keyset_info.issuer_version);
 }
 
 /// Test adding duplicate keyset info is idempotent
@@ -62,6 +65,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info first time
@@ -97,6 +101,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id2 = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -110,6 +115,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset infos
@@ -141,6 +147,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info
@@ -173,6 +180,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id_usd = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -186,6 +194,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset infos and set as active
@@ -223,6 +232,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id2 = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -236,6 +246,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add both keysets and set first as active

+ 2 - 0
crates/cdk-common/src/database/mint/test/mod.rs

@@ -12,6 +12,7 @@ use bitcoin::bip32::DerivationPath;
 use cashu::CurrencyUnit;
 
 use super::*;
+use crate::common::IssuerVersion;
 use crate::database::KVStoreDatabase;
 use crate::mint::MintKeySetInfo;
 
@@ -49,6 +50,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
     let mut writer = db.begin_transaction().await.expect("db.begin()");
     writer.add_keyset_info(keyset_info).await.unwrap();

+ 3 - 0
crates/cdk-common/src/mint.rs

@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
 use tracing::instrument;
 use uuid::Uuid;
 
+use crate::common::IssuerVersion;
 use crate::nuts::{MeltQuoteState, MintQuoteState};
 use crate::payment::PaymentIdentifier;
 use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
@@ -885,6 +886,8 @@ pub struct MintKeySetInfo {
     pub input_fee_ppk: u64,
     /// Final expiry
     pub final_expiry: Option<u64>,
+    /// Issuer Version
+    pub issuer_version: Option<IssuerVersion>,
 }
 
 /// Default fee

+ 3 - 0
crates/cdk-signatory/src/common.rs

@@ -1,8 +1,10 @@
 use std::collections::HashMap;
+use std::str::FromStr;
 use std::sync::Arc;
 
 use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
 use bitcoin::secp256k1::{self, All, Secp256k1};
+use cdk_common::common::IssuerVersion;
 use cdk_common::database;
 use cdk_common::error::Error;
 use cdk_common::mint::MintKeySetInfo;
@@ -106,6 +108,7 @@ pub fn create_new_keyset<C: secp256k1::Signing>(
         derivation_path_index,
         amounts: amounts.to_owned(),
         input_fee_ppk,
+        issuer_version: IssuerVersion::from_str(&format!("cdk/{}", env!("CARGO_PKG_VERSION"))).ok(),
     };
     (keyset, keyset_info)
 }

+ 9 - 0
crates/cdk-signatory/src/proto/convert.rs

@@ -1,6 +1,8 @@
 //! Type conversions between Rust types and the generated protobuf types.
 use std::collections::BTreeMap;
+use std::str::FromStr;
 
+use cdk_common::common::IssuerVersion;
 use cdk_common::secret::Secret;
 use cdk_common::util::hex;
 use cdk_common::{Amount, Id, PublicKey};
@@ -63,6 +65,11 @@ impl TryInto<crate::signatory::SignatoryKeySet> for KeySet {
             amounts: keys.keys().map(|x| x.to_u64()).collect::<Vec<_>>(),
             keys: cdk_common::Keys::new(keys),
             final_expiry: self.final_expiry,
+            issuer_version: self
+                .issuer_version
+                .map(|v| IssuerVersion::from_str(&v))
+                .transpose()
+                .map_err(|e| cdk_common::Error::Custom(e.to_string()))?,
         })
     }
 }
@@ -83,6 +90,7 @@ impl From<crate::signatory::SignatoryKeySet> for KeySet {
             }),
             final_expiry: keyset.final_expiry,
             version: Default::default(),
+            issuer_version: keyset.issuer_version.map(|v| v.to_string()),
         }
     }
 }
@@ -368,6 +376,7 @@ impl From<cdk_common::KeySetInfo> for KeySet {
             keys: Default::default(),
             final_expiry: value.final_expiry,
             version: Default::default(),
+            issuer_version: None,
         }
     }
 }

+ 1 - 0
crates/cdk-signatory/src/proto/signatory.proto

@@ -64,6 +64,7 @@ message KeySet {
   Keys keys = 5;
   optional uint64 final_expiry = 6;
   uint64 version = 7;
+  optional string issuer_version = 8;
 }
 
 message Keys {

+ 5 - 0
crates/cdk-signatory/src/signatory.rs

@@ -6,6 +6,7 @@
 //! There is an in memory implementation, when the keys are stored in memory, in the same process,
 //! but it is isolated from the rest of the application, and they communicate through a channel with
 //! the defined API.
+use cdk_common::common::IssuerVersion;
 use cdk_common::error::Error;
 use cdk_common::mint::MintKeySetInfo;
 use cdk_common::{
@@ -79,6 +80,8 @@ pub struct SignatoryKeySet {
     pub input_fee_ppk: u64,
     /// Final expiry of the keyset (unix timestamp in the future)
     pub final_expiry: Option<u64>,
+    /// Issuer Version
+    pub issuer_version: Option<IssuerVersion>,
 }
 
 impl From<&SignatoryKeySet> for KeySet {
@@ -117,6 +120,7 @@ impl From<SignatoryKeySet> for MintKeySetInfo {
             derivation_path_index: Default::default(),
             amounts: val.amounts,
             final_expiry: val.final_expiry,
+            issuer_version: val.issuer_version,
             valid_from: 0,
         }
     }
@@ -132,6 +136,7 @@ impl From<&(MintKeySetInfo, MintKeySet)> for SignatoryKeySet {
             amounts: info.amounts.clone(),
             keys: key.keys.clone().into(),
             final_expiry: key.final_expiry,
+            issuer_version: info.issuer_version.clone(),
         }
     }
 }

+ 33 - 6
crates/cdk-sql-common/src/mint/keys.rs

@@ -5,6 +5,7 @@ use std::str::FromStr;
 
 use async_trait::async_trait;
 use bitcoin::bip32::DerivationPath;
+use cdk_common::common::IssuerVersion;
 use cdk_common::database::{Error, MintKeyDatabaseTransaction, MintKeysDatabase};
 use cdk_common::mint::MintKeySetInfo;
 use cdk_common::{CurrencyUnit, Id};
@@ -29,7 +30,8 @@ pub(crate) fn sql_row_to_keyset_info(row: Vec<Column>) -> Result<MintKeySetInfo,
             derivation_path,
             derivation_path_index,
             amounts,
-            row_keyset_ppk
+            row_keyset_ppk,
+            issuer_version
         ) = row
     );
 
@@ -47,6 +49,19 @@ pub(crate) fn sql_row_to_keyset_info(row: Vec<Column>) -> Result<MintKeySetInfo,
         amounts,
         input_fee_ppk: column_as_nullable_number!(row_keyset_ppk).unwrap_or(0),
         final_expiry: column_as_nullable_number!(valid_to),
+        issuer_version: column_as_nullable_string!(issuer_version).and_then(|v| {
+            match IssuerVersion::from_str(&v) {
+                Ok(ver) => Some(ver),
+                Err(e) => {
+                    tracing::warn!(
+                        "Failed to parse issuer_version from database: {}. Error: {}",
+                        v,
+                        e
+                    );
+                    None
+                }
+            }
+        }),
     })
 }
 
@@ -61,11 +76,11 @@ where
         INSERT INTO
             keyset (
                 id, unit, active, valid_from, valid_to, derivation_path,
-                amounts, input_fee_ppk, derivation_path_index
+                amounts, input_fee_ppk, derivation_path_index, issuer_version
             )
         VALUES (
             :id, :unit, :active, :valid_from, :valid_to, :derivation_path,
-            :amounts, :input_fee_ppk, :derivation_path_index
+            :amounts, :input_fee_ppk, :derivation_path_index, :issuer_version
         )
         ON CONFLICT(id) DO UPDATE SET
             unit = excluded.unit,
@@ -75,7 +90,8 @@ where
             derivation_path = excluded.derivation_path,
             amounts = excluded.amounts,
             input_fee_ppk = excluded.input_fee_ppk,
-            derivation_path_index = excluded.derivation_path_index
+            derivation_path_index = excluded.derivation_path_index,
+            issuer_version = excluded.issuer_version
         "#,
         )?
         .bind("id", keyset.id.to_string())
@@ -87,6 +103,10 @@ where
         .bind("amounts", serde_json::to_string(&keyset.amounts).ok())
         .bind("input_fee_ppk", keyset.input_fee_ppk as i64)
         .bind("derivation_path_index", keyset.derivation_path_index)
+        .bind(
+            "issuer_version",
+            keyset.issuer_version.map(|v| v.to_string()),
+        )
         .execute(&self.inner)
         .await?;
 
@@ -176,7 +196,8 @@ where
                 derivation_path,
                 derivation_path_index,
                 amounts,
-                input_fee_ppk
+                input_fee_ppk,
+                issuer_version
             FROM
                 keyset
                 WHERE id=:id"#,
@@ -200,7 +221,8 @@ where
                 derivation_path,
                 derivation_path_index,
                 amounts,
-                input_fee_ppk
+                input_fee_ppk,
+                issuer_version
             FROM
                 keyset
             "#,
@@ -233,10 +255,15 @@ mod test {
                 Column::Integer(0),
                 Column::Text(serde_json::to_string(&amounts).expect("valid json")),
                 Column::Integer(0),
+                Column::Text("cdk/0.1.0".to_owned()),
             ]);
             assert!(result.is_ok());
             let keyset = result.unwrap();
             assert_eq!(keyset.amounts.len(), 32);
+            assert_eq!(
+                keyset.issuer_version,
+                Some(IssuerVersion::from_str("cdk/0.1.0").unwrap())
+            );
         }
     }
 }

+ 1 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20260122000000_add_issuer_version_to_keyset.sql

@@ -0,0 +1 @@
+ALTER TABLE keyset ADD COLUMN issuer_version TEXT;

+ 1 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20260122000000_add_issuer_version_to_keyset.sql

@@ -0,0 +1 @@
+ALTER TABLE keyset ADD COLUMN issuer_version TEXT;

+ 0 - 3
crates/cdk/src/lib.rs

@@ -87,6 +87,3 @@ pub mod http_client {
 /// Re-export futures::Stream
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub use futures::{Stream, StreamExt};
-/// Payment Request
-#[cfg(feature = "wallet")]
-pub use wallet::payment_request;