diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index 99b35101..85f33a97 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -12,6 +12,7 @@ use std::string::FromUtf8Error; use serde::{de, Deserialize, Deserializer, Serialize}; use thiserror::Error; +use super::nut02::ShortKeysetId; #[cfg(feature = "wallet")] use super::nut10; #[cfg(feature = "wallet")] @@ -183,6 +184,9 @@ pub enum Error { /// NUT11 error #[error(transparent)] NUT11(#[from] crate::nuts::nut11::Error), + /// Short keyset id -> id error + #[error(transparent)] + NUT02(#[from] crate::nuts::nut02::Error), } /// Blinded Message (also called `output`) @@ -434,6 +438,12 @@ impl ProofV4 { } } +impl Hash for ProofV4 { + fn hash(&self, state: &mut H) { + self.secret.hash(state); + } +} + impl From for ProofV4 { fn from(proof: Proof) -> ProofV4 { let Proof { @@ -454,6 +464,80 @@ impl From for ProofV4 { } } +impl From for ProofV4 { + fn from(proof: ProofV3) -> Self { + Self { + amount: proof.amount, + secret: proof.secret, + c: proof.c, + witness: proof.witness, + dleq: proof.dleq, + } + } +} + +/// Proof v3 with short keyset id +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProofV3 { + /// Amount + pub amount: Amount, + /// Short keyset id + #[serde(rename = "id")] + pub keyset_id: ShortKeysetId, + /// Secret message + pub secret: Secret, + /// Unblinded signature + #[serde(rename = "C")] + pub c: PublicKey, + /// Witness + #[serde(skip_serializing_if = "Option::is_none")] + pub witness: Option, + /// DLEQ Proof + #[serde(skip_serializing_if = "Option::is_none")] + pub dleq: Option, +} + +impl ProofV3 { + /// [`ProofV3`] into [`Proof`] + pub fn into_proof(&self, keyset_id: &Id) -> Proof { + Proof { + amount: self.amount, + keyset_id: *keyset_id, + secret: self.secret.clone(), + c: self.c, + witness: self.witness.clone(), + dleq: self.dleq.clone(), + } + } +} + +impl From for ProofV3 { + fn from(proof: Proof) -> ProofV3 { + let Proof { + amount, + keyset_id, + secret, + c, + witness, + dleq, + } = proof; + ProofV3 { + amount, + secret, + c, + witness, + dleq, + keyset_id: keyset_id.into(), + } + } +} + +impl Hash for ProofV3 { + fn hash(&self, state: &mut H) { + self.secret.hash(state); + } +} + fn serialize_v4_pubkey(key: &PublicKey, serializer: S) -> Result where S: serde::Serializer, diff --git a/crates/cashu/src/nuts/nut00/token.rs b/crates/cashu/src/nuts/nut00/token.rs index a3e6274b..f054875f 100644 --- a/crates/cashu/src/nuts/nut00/token.rs +++ b/crates/cashu/src/nuts/nut00/token.rs @@ -10,11 +10,11 @@ use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine as _}; use serde::{Deserialize, Serialize}; -use super::{Error, Proof, ProofV4, Proofs}; +use super::{Error, Proof, ProofV3, ProofV4, Proofs}; use crate::mint_url::MintUrl; -use crate::nuts::nut00::ProofsMethods; +use crate::nut02::ShortKeysetId; use crate::nuts::{CurrencyUnit, Id}; -use crate::{ensure_cdk, Amount}; +use crate::{ensure_cdk, Amount, KeySetInfo}; /// Token Enum #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -66,10 +66,10 @@ impl Token { } /// Proofs in [`Token`] - pub fn proofs(&self) -> Proofs { + pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result { match self { - Self::TokenV3(token) => token.proofs(), - Self::TokenV4(token) => token.proofs(), + Self::TokenV3(token) => token.proofs(mint_keysets), + Self::TokenV4(token) => token.proofs(mint_keysets), } } @@ -181,8 +181,8 @@ impl TryFrom<&Vec> for Token { pub struct TokenV3Token { /// Url of mint pub mint: MintUrl, - /// [`Proofs`] - pub proofs: Proofs, + /// [`Vec`] + pub proofs: Vec, } impl TokenV3Token { @@ -190,7 +190,7 @@ impl TokenV3Token { pub fn new(mint_url: MintUrl, proofs: Proofs) -> Self { Self { mint: mint_url, - proofs, + proofs: proofs.into_iter().map(ProofV3::from).collect(), } } } @@ -226,17 +226,21 @@ impl TokenV3 { } /// Proofs - pub fn proofs(&self) -> Proofs { - self.token - .iter() - .flat_map(|token| token.proofs.clone()) - .collect() + pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result { + let mut proofs: Proofs = vec![]; + for t in self.token.iter() { + for p in t.proofs.iter() { + let long_id = Id::from_short_keyset_id(&p.keyset_id, mint_keysets)?; + proofs.push(p.into_proof(&long_id)); + } + } + Ok(proofs) } /// Value - errors if duplicate proofs are found #[inline] pub fn value(&self) -> Result { - let proofs = self.proofs(); + let proofs: Vec = self.token.iter().flat_map(|t| t.proofs.clone()).collect(); let unique_count = proofs .iter() .collect::>() @@ -247,7 +251,12 @@ impl TokenV3 { return Err(Error::DuplicateProofs); } - proofs.total_amount() + Ok(Amount::try_sum( + self.token + .iter() + .map(|t| Amount::try_sum(t.proofs.iter().map(|p| p.amount))) + .collect::, _>>()?, + )?) } /// Memo @@ -306,10 +315,27 @@ impl fmt::Display for TokenV3 { impl From for TokenV3 { fn from(token: TokenV4) -> Self { - let proofs = token.proofs(); + let proofs: Vec = token + .token + .into_iter() + .flat_map(|token| { + token.proofs.into_iter().map(move |p| ProofV3 { + amount: p.amount, + keyset_id: token.keyset_id.clone(), + secret: p.secret, + c: p.c, + witness: p.witness, + dleq: p.dleq, + }) + }) + .collect(); + let token_v3_token = TokenV3Token { + mint: token.mint_url, + proofs, + }; TokenV3 { - token: vec![TokenV3Token::new(token.mint_url, proofs)], + token: vec![token_v3_token], memo: token.memo, unit: Some(token.unit), } @@ -335,17 +361,19 @@ pub struct TokenV4 { impl TokenV4 { /// Proofs from token - pub fn proofs(&self) -> Proofs { - self.token - .iter() - .flat_map(|token| token.proofs.iter().map(|p| p.into_proof(&token.keyset_id))) - .collect() + pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result { + let mut proofs: Proofs = vec![]; + for t in self.token.iter() { + let long_id = Id::from_short_keyset_id(&t.keyset_id, mint_keysets)?; + proofs.extend(t.proofs.iter().map(|p| p.into_proof(&long_id))); + } + Ok(proofs) } /// Value - errors if duplicate proofs are found #[inline] pub fn value(&self) -> Result { - let proofs = self.proofs(); + let proofs: Vec = self.token.iter().flat_map(|t| t.proofs.clone()).collect(); let unique_count = proofs .iter() .collect::>() @@ -356,7 +384,12 @@ impl TokenV4 { return Err(Error::DuplicateProofs); } - proofs.total_amount() + Ok(Amount::try_sum( + self.token + .iter() + .map(|t| Amount::try_sum(t.proofs.iter().map(|p| p.amount))) + .collect::, _>>()?, + )?) } /// Memo @@ -421,23 +454,29 @@ impl TryFrom<&Vec> for TokenV4 { impl TryFrom for TokenV4 { type Error = Error; fn try_from(token: TokenV3) -> Result { - let proofs = token.proofs(); let mint_urls = token.mint_urls(); + let proofs: Vec = token.token.into_iter().flat_map(|t| t.proofs).collect(); ensure_cdk!(mint_urls.len() == 1, Error::UnsupportedToken); let mint_url = mint_urls.first().ok_or(Error::UnsupportedToken)?; let proofs = proofs - .iter() - .fold(HashMap::new(), |mut acc, val| { - acc.entry(val.keyset_id) - .and_modify(|p: &mut Vec| p.push(val.clone())) - .or_insert(vec![val.clone()]); - acc - }) .into_iter() - .map(|(id, proofs)| TokenV4Token::new(id, proofs)) + .fold( + HashMap::>::new(), + |mut acc, val| { + acc.entry(val.keyset_id.clone()) + .and_modify(|p: &mut Vec| p.push(val.clone().into())) + .or_insert(vec![val.clone().into()]); + acc + }, + ) + .into_iter() + .map(|(id, proofs)| TokenV4Token { + keyset_id: id, + proofs, + }) .collect(); Ok(TokenV4 { @@ -458,32 +497,34 @@ pub struct TokenV4Token { serialize_with = "serialize_v4_keyset_id", deserialize_with = "deserialize_v4_keyset_id" )] - pub keyset_id: Id, + pub keyset_id: ShortKeysetId, /// Proofs #[serde(rename = "p")] pub proofs: Vec, } -fn serialize_v4_keyset_id(keyset_id: &Id, serializer: S) -> Result +fn serialize_v4_keyset_id(keyset_id: &ShortKeysetId, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_bytes(&keyset_id.to_bytes()) } -fn deserialize_v4_keyset_id<'de, D>(deserializer: D) -> Result +fn deserialize_v4_keyset_id<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let bytes = Vec::::deserialize(deserializer)?; - Id::from_bytes(&bytes).map_err(serde::de::Error::custom) + ShortKeysetId::from_bytes(&bytes).map_err(serde::de::Error::custom) } impl TokenV4Token { /// Create new [`TokenV4Token`] pub fn new(keyset_id: Id, proofs: Proofs) -> Self { + // Create a short keyset id from id + let short_id = ShortKeysetId::from(keyset_id); Self { - keyset_id, + keyset_id: short_id, proofs: proofs.into_iter().map(|p| p.into()).collect(), } } @@ -493,7 +534,10 @@ impl TokenV4Token { mod tests { use std::str::FromStr; + use bip39::rand::{self, RngCore}; + use super::*; + use crate::dhke::hash_to_curve; use crate::mint_url::MintUrl; use crate::secret::Secret; use crate::util::hex; @@ -522,7 +566,7 @@ mod tests { ); assert_eq!( token.token[0].keyset_id, - Id::from_str("00ad268c4d1f5826").unwrap() + ShortKeysetId::from_str("00ad268c4d1f5826").unwrap() ); let encoded = &token.to_string(); @@ -546,12 +590,13 @@ mod tests { match token { Token::TokenV4(token) => { - let tokens: Vec = token.token.iter().map(|t| t.keyset_id).collect(); + let tokens: Vec = + token.token.iter().map(|t| t.keyset_id.clone()).collect(); assert_eq!(tokens.len(), 2); - assert!(tokens.contains(&Id::from_str("00ffd48b8f5ecf80").unwrap())); - assert!(tokens.contains(&Id::from_str("00ad268c4d1f5826").unwrap())); + assert!(tokens.contains(&ShortKeysetId::from_str("00ffd48b8f5ecf80").unwrap())); + assert!(tokens.contains(&ShortKeysetId::from_str("00ad268c4d1f5826").unwrap())); let mint_url = token.mint_url; @@ -584,7 +629,7 @@ mod tests { ); assert_eq!( token.token[0].proofs[0].clone().keyset_id, - Id::from_str("009a1f293253e41e").unwrap() + ShortKeysetId::from_str("009a1f293253e41e").unwrap() ); assert_eq!(token.unit.clone().unwrap(), CurrencyUnit::Sat); @@ -684,4 +729,101 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap(), Amount::from(20)); } + + #[test] + fn test_token_from_proofs_with_idv2_round_trip() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + + let keysets_info: Vec = (0..10) + .map(|_| { + let mut bytes: [u8; 33] = [0u8; 33]; + bytes[0] = 1u8; + rand::thread_rng().fill_bytes(&mut bytes[1..]); + let id = Id::from_bytes(&bytes).unwrap(); + KeySetInfo { + id, + unit: CurrencyUnit::Sat, + active: true, + input_fee_ppk: 0, + final_expiry: None, + } + }) + .collect(); + + let chosen_keyset_id = keysets_info[0].id; + // Make up a bunch of fake proofs + let proofs = (0..5) + .map(|_| { + let mut c_preimage: [u8; 33] = [0u8; 33]; + c_preimage[0] = 1u8; + rand::thread_rng().fill_bytes(&mut c_preimage[1..]); + Proof::new( + Amount::from(1), + chosen_keyset_id, + Secret::generate(), + hash_to_curve(&c_preimage).unwrap(), + ) + }) + .collect(); + + let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat); + let token_str = token.to_string(); + + let token1 = Token::from_str(&token_str); + assert!(token1.is_ok()); + + let proofs1 = token1.unwrap().proofs(&keysets_info); + assert!(proofs1.is_ok()); + + //println!("{:?}", proofs1); + } + + #[test] + fn test_token_proofs_with_unknown_short_keyset_id() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + + let keysets_info: Vec = (0..10) + .map(|_| { + let mut bytes: [u8; 33] = [0u8; 33]; + bytes[0] = 1u8; + rand::thread_rng().fill_bytes(&mut bytes[1..]); + let id = Id::from_bytes(&bytes).unwrap(); + KeySetInfo { + id, + unit: CurrencyUnit::Sat, + active: true, + input_fee_ppk: 0, + final_expiry: None, + } + }) + .collect(); + + let chosen_keyset_id = + Id::from_str("01c352c0b47d42edb764bddf8c53d77b85f057157d92084d9d05e876251ecd8422") + .unwrap(); + + // Make up a bunch of fake proofs + let proofs = (0..5) + .map(|_| { + let mut c_preimage: [u8; 33] = [0u8; 33]; + c_preimage[0] = 1u8; + rand::thread_rng().fill_bytes(&mut c_preimage[1..]); + Proof::new( + Amount::from(1), + chosen_keyset_id, + Secret::generate(), + hash_to_curve(&c_preimage).unwrap(), + ) + }) + .collect(); + + let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat); + let token_str = token.to_string(); + + let token1 = Token::from_str(&token_str); + assert!(token1.is_ok()); + + let proofs1 = token1.unwrap().proofs(&keysets_info); + assert!(proofs1.is_err()); + } } diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index 4f7f5f90..bfb3f07e 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -42,6 +42,12 @@ pub enum Error { /// Keyset id does not match #[error("Keyset id incorrect")] IncorrectKeysetId, + /// Short keyset id does not match any of the provided IDv2s + #[error("Short keyset id does not match any of the provided IDv2s")] + UnknownShortKeysetId, + /// Short keyset id is ill-formed + #[error("Short keyset id is ill-formed")] + MalformedShortKeysetId, /// Slice Error #[error(transparent)] Slice(#[from] TryFromSliceError), @@ -51,8 +57,10 @@ pub enum Error { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum KeySetVersion { - /// Current Version 00 + /// Version 00 Version00, + /// Version 01 + Version01, } impl KeySetVersion { @@ -60,6 +68,7 @@ impl KeySetVersion { pub fn to_byte(&self) -> u8 { match self { Self::Version00 => 0, + Self::Version01 => 1, } } @@ -67,6 +76,7 @@ impl KeySetVersion { pub fn from_byte(byte: &u8) -> Result { match byte { 0 => Ok(Self::Version00), + 1 => Ok(Self::Version01), _ => Err(Error::UnknownVersion), } } @@ -76,6 +86,27 @@ impl fmt::Display for KeySetVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { KeySetVersion::Version00 => f.write_str("00"), + KeySetVersion::Version01 => f.write_str("01"), + } + } +} + +/// Keyset ID bytes +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub enum IdBytes { + /// Bytes for v1 + V1([u8; 7]), + /// Bytes for v2 + V2([u8; 32]), +} + +impl IdBytes { + /// Convert [`IdBytes`] to [`Vec`] + pub fn to_vec(&self) -> Vec { + match self { + IdBytes::V1(bytes) => bytes.to_vec(), + IdBytes::V2(bytes) => bytes.to_vec(), } } } @@ -89,12 +120,14 @@ impl fmt::Display for KeySetVersion { #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct Id { version: KeySetVersion, - id: [u8; Self::BYTELEN], + id: IdBytes, } impl Id { - const STRLEN: usize = 14; - const BYTELEN: usize = 7; + const STRLEN_V1: usize = 14; + const BYTELEN_V1: usize = 7; + const STRLEN_V2: usize = 64; + const BYTELEN_V2: usize = 32; /// [`Id`] to bytes pub fn to_bytes(&self) -> Vec { @@ -103,18 +136,122 @@ impl Id { /// [`Id`] from bytes pub fn from_bytes(bytes: &[u8]) -> Result { - Ok(Self { - version: KeySetVersion::from_byte(&bytes[0])?, - id: bytes[1..].try_into()?, - }) + let version = KeySetVersion::from_byte(&bytes[0])?; + let id = match version { + KeySetVersion::Version00 => IdBytes::V1(bytes[1..].try_into()?), + KeySetVersion::Version01 => IdBytes::V2(bytes[1..].try_into()?), + }; + Ok(Self { version, id }) } - /// [`Id`] as bytes - pub fn as_bytes(&self) -> [u8; Self::BYTELEN + 1] { - let mut bytes = [0u8; Self::BYTELEN + 1]; - bytes[0] = self.version.to_byte(); - bytes[1..].copy_from_slice(&self.id); - bytes + /// Get the version of the keyset + pub fn get_version(&self) -> KeySetVersion { + self.version + } + + /// *** V2 KEYSET *** + /// create [`Id`] v2 from keys, unit and (optionally) expiry + /// 1 - sort public keys by their amount in ascending order + /// 2 - concatenate all public keys to one byte array + /// 3 - concatenate the lowercase unit string to the byte array (e.g. "unit:sat") + /// 4 - If a final expiration is specified, convert it into a radix-10 string and concatenate it (e.g "final_expiry:1896187313") + /// 5 - HASH_SHA256 the concatenated byte array and take the first 31 bytes + /// 6 - prefix it with a keyset ID version byte + pub fn v2_from_data(map: &Keys, unit: &CurrencyUnit, expiry: Option) -> Self { + let mut keys: Vec<(&Amount, &super::PublicKey)> = map.iter().collect(); + keys.sort_by_key(|(amt, _v)| *amt); + + let mut pubkeys_concat: Vec = keys + .iter() + .map(|(_, pubkey)| pubkey.to_bytes()) + .collect::>() + .concat(); + + // Add the unit + pubkeys_concat.extend(b"unit:"); + pubkeys_concat.extend(unit.to_string().to_lowercase().as_bytes()); + + // Add the expiration + if let Some(expiry) = expiry { + pubkeys_concat.extend(b"final_expiry:"); + pubkeys_concat.extend(expiry.to_string().as_bytes()); + } + + let hash = Sha256::hash(&pubkeys_concat); + let hex_of_hash = hex::encode(hash.to_byte_array()); + + Self { + version: KeySetVersion::Version01, + id: IdBytes::V2( + hex::decode(&hex_of_hash[0..Self::STRLEN_V2]) + .expect("Keys hash could not be hex decoded") + .try_into() + .expect("Invalid length of hex id"), + ), + } + } + + /// *** V1 VERSION *** + /// As per NUT-02: + /// 1. sort public keys by their amount in ascending order + /// 2. concatenate all public keys to one string + /// 3. HASH_SHA256 the concatenated public keys + /// 4. take the first 14 characters of the hex-encoded hash + /// 5. prefix it with a keyset ID version byte + pub fn v1_from_keys(map: &Keys) -> Self { + let mut keys: Vec<(&Amount, &super::PublicKey)> = map.iter().collect(); + keys.sort_by_key(|(amt, _v)| *amt); + + let pubkeys_concat: Vec = keys + .iter() + .map(|(_, pubkey)| pubkey.to_bytes()) + .collect::>() + .concat(); + + let hash = Sha256::hash(&pubkeys_concat); + let hex_of_hash = hex::encode(hash.to_byte_array()); + + Self { + version: KeySetVersion::Version00, + id: IdBytes::V1( + hex::decode(&hex_of_hash[0..Self::STRLEN_V1]) + .expect("Keys hash could not be hex decoded") + .try_into() + .expect("Invalid length of hex id"), + ), + } + } + + /// Selects the correct IDv2 from a list of keysets and the given short-id + /// or returns the short-id in the case of v1. + pub fn from_short_keyset_id( + short_id: &ShortKeysetId, + keysets_info: &[KeySetInfo], + ) -> Result { + // Check prefix length + if short_id.prefix.len() < Self::BYTELEN_V1 || short_id.prefix.len() > Self::BYTELEN_V2 { + return Err(Error::MalformedShortKeysetId); + } + + match short_id.version { + KeySetVersion::Version00 => { + let mut idbytes: [u8; Self::BYTELEN_V1] = [0u8; Self::BYTELEN_V1]; + idbytes.copy_from_slice(&short_id.prefix[..Self::BYTELEN_V1]); + Ok(Self { + version: short_id.version, + id: IdBytes::V1(idbytes), + }) + } + KeySetVersion::Version01 => { + // We return the first match or error + for keyset_info in keysets_info.iter() { + if keyset_info.id.id.to_vec()[..short_id.prefix.len()] == short_id.prefix { + return Ok(keyset_info.id); + } + } + Err(Error::UnknownShortKeysetId) + } + } } } @@ -122,7 +259,9 @@ impl Id { // This is a one-way function impl From for u32 { fn from(value: Id) -> Self { - let hex_bytes: [u8; 8] = value.as_bytes(); + let id_bytes = value.to_bytes(); + let mut hex_bytes: [u8; 8] = [0; 8]; + hex_bytes.copy_from_slice(&id_bytes[..8]); let int = u64::from_be_bytes(hex_bytes); @@ -132,13 +271,21 @@ impl From for u32 { impl fmt::Display for Id { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&format!("{}{}", self.version, hex::encode(self.id))) + let hex_id = match self.id { + IdBytes::V1(id) => hex::encode(id), + IdBytes::V2(id) => hex::encode(id), + }; + f.write_str(&format!("{}{}", self.version, hex_id)) } } impl fmt::Debug for Id { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(&format!("{}{}", self.version, hex::encode(self.id))) + let hex_id = match self.id { + IdBytes::V1(id) => hex::encode(id), + IdBytes::V2(id) => hex::encode(id), + }; + f.write_str(&format!("{}{}", self.version, hex_id)) } } @@ -146,14 +293,26 @@ impl TryFrom for Id { type Error = Error; fn try_from(s: String) -> Result { - ensure_cdk!(s.len() == 16, Error::Length); + ensure_cdk!( + s.len() == Self::STRLEN_V1 + 2 || s.len() == Self::STRLEN_V2 + 2, + Error::Length + ); - Ok(Self { - version: KeySetVersion::from_byte(&hex::decode(&s[..2])?[0])?, - id: hex::decode(&s[2..])? - .try_into() - .map_err(|_| Error::Length)?, - }) + let version: KeySetVersion = KeySetVersion::from_byte(&hex::decode(&s[..2])?[0])?; + let id = match version { + KeySetVersion::Version00 => IdBytes::V1( + hex::decode(&s[2..])? + .try_into() + .map_err(|_| Error::Length)?, + ), + KeySetVersion::Version01 => IdBytes::V2( + hex::decode(&s[2..])? + .try_into() + .map_err(|_| Error::Length)?, + ), + }; + + Ok(Self { version, id }) } } @@ -171,33 +330,88 @@ impl From for String { } } -impl From<&Keys> for Id { - /// As per NUT-02: - /// 1. sort public keys by their amount in ascending order - /// 2. concatenate all public keys to one string - /// 3. HASH_SHA256 the concatenated public keys - /// 4. take the first 14 characters of the hex-encoded hash - /// 5. prefix it with a keyset ID version byte - fn from(map: &Keys) -> Self { - let mut keys: Vec<(&Amount, &super::PublicKey)> = map.iter().collect(); - keys.sort_by_key(|(amt, _v)| *amt); +/// Improper prefix of the keyset ID. In case of v1, this is the whole ID. +/// In case of v2, this is the 8-byte prefix +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct ShortKeysetId { + /// The version of the short keyset + version: KeySetVersion, + /// The improper prefix of the keyset ID bytes + prefix: Vec, +} - let pubkeys_concat: Vec = keys - .iter() - .map(|(_, pubkey)| pubkey.to_bytes()) - .collect::>() - .concat(); +impl ShortKeysetId { + /// [`ShortKeysetId`] to bytes + pub fn to_bytes(&self) -> Vec { + [vec![self.version.to_byte()], self.prefix.clone()].concat() + } - let hash = Sha256::hash(&pubkeys_concat); - let hex_of_hash = hex::encode(hash.to_byte_array()); + /// [`ShortKeysetId`] from bytes + pub fn from_bytes(bytes: &[u8]) -> Result { + let version = KeySetVersion::from_byte(&bytes[0])?; + let prefix = bytes[1..].to_vec(); + Ok(Self { version, prefix }) + } +} - Self { - version: KeySetVersion::Version00, - id: hex::decode(&hex_of_hash[0..Self::STRLEN]) - .expect("Keys hash could not be hex decoded") - .try_into() - .expect("Invalid length of hex id"), - } +impl From for ShortKeysetId { + fn from(id: Id) -> Self { + let version = id.version; + let prefix: Vec = match id.version { + KeySetVersion::Version00 => match id.id { + IdBytes::V1(idbytes) => Vec::from(&idbytes), + _ => panic!("Unexpected IdBytes length"), + }, + KeySetVersion::Version01 => match id.id { + IdBytes::V2(idbytes) => Vec::from(&idbytes[..7]), + _ => panic!("Unexpected IdBytes length"), + }, + }; + + Self { version, prefix } + } +} + +impl fmt::Display for ShortKeysetId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let hex_id = hex::encode(&self.prefix); + f.write_str(&format!("{}{}", self.version, hex_id)) + } +} + +impl fmt::Debug for ShortKeysetId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let hex_id = hex::encode(&self.prefix); + f.write_str(&format!("{}{}", self.version, hex_id)) + } +} + +impl TryFrom for ShortKeysetId { + type Error = Error; + + fn try_from(s: String) -> Result { + ensure_cdk!(s.len() == 16, Error::Length); + + let version: KeySetVersion = KeySetVersion::from_byte(&hex::decode(&s[..2])?[0])?; + let prefix = hex::decode(&s[2..])?; + + Ok(Self { version, prefix }) + } +} + +impl FromStr for ShortKeysetId { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::try_from(s.to_string()) + } +} + +impl From for String { + fn from(value: ShortKeysetId) -> Self { + value.to_string() } } @@ -223,14 +437,26 @@ pub struct KeySet { pub unit: CurrencyUnit, /// Keyset [`Keys`] pub keys: Keys, + /// Expiry + #[serde(skip_serializing_if = "Option::is_none")] + pub final_expiry: Option, } impl KeySet { - /// Verify the keyset is matches keys + /// Verify the keyset id matches keys pub fn verify_id(&self) -> Result<(), Error> { - let keys_id: Id = (&self.keys).into(); + match self.id.version { + KeySetVersion::Version00 => { + let keys_id: Id = Id::v1_from_keys(&self.keys); - ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); + ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); + } + KeySetVersion::Version01 => { + let keys_id: Id = Id::v2_from_data(&self.keys, &self.unit, self.final_expiry); + + ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); + } + } Ok(()) } @@ -243,6 +469,7 @@ impl From for KeySet { id: keyset.id, unit: keyset.unit, keys: Keys::from(keyset.keys), + final_expiry: keyset.final_expiry, } } } @@ -265,6 +492,9 @@ pub struct KeySetInfo { default = "default_input_fee_ppk" )] pub input_fee_ppk: u64, + /// Expiry of the keyset + #[serde(skip_serializing_if = "Option::is_none")] + pub final_expiry: Option, } fn deserialize_input_fee_ppk<'de, D>(deserializer: D) -> Result @@ -290,6 +520,9 @@ pub struct MintKeySet { pub unit: CurrencyUnit, /// Keyset [`MintKeys`] pub keys: MintKeys, + #[serde(skip_serializing_if = "Option::is_none")] + /// Expiry [`Option`] + pub final_expiry: Option, } #[cfg(feature = "mint")] @@ -300,6 +533,8 @@ impl MintKeySet { xpriv: Xpriv, unit: CurrencyUnit, max_order: u8, + final_expiry: Option, + version: KeySetVersion, ) -> Self { let mut map = BTreeMap::new(); for i in 0..max_order { @@ -322,10 +557,15 @@ impl MintKeySet { } let keys = MintKeys::new(map); + let id = match version { + KeySetVersion::Version00 => Id::v1_from_keys(&keys.clone().into()), + KeySetVersion::Version01 => Id::v2_from_data(&keys.clone().into(), &unit, final_expiry), + }; Self { - id: (&keys).into(), + id, unit, keys, + final_expiry, } } @@ -336,6 +576,8 @@ impl MintKeySet { max_order: u8, currency_unit: CurrencyUnit, derivation_path: DerivationPath, + final_expiry: Option, + version: KeySetVersion, ) -> Self { let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted"); Self::generate( @@ -345,6 +587,8 @@ impl MintKeySet { .expect("RNG busted"), currency_unit, max_order, + final_expiry, + version, ) } @@ -355,6 +599,8 @@ impl MintKeySet { max_order: u8, currency_unit: CurrencyUnit, derivation_path: DerivationPath, + final_expiry: Option, + version: KeySetVersion, ) -> Self { Self::generate( secp, @@ -363,6 +609,8 @@ impl MintKeySet { .expect("RNG busted"), currency_unit, max_order, + final_expiry, + version, ) } } @@ -371,8 +619,10 @@ impl MintKeySet { impl From for Id { fn from(keyset: MintKeySet) -> Id { let keys: super::KeySet = keyset.into(); - - Id::from(&keys.keys) + match keys.id.version { + KeySetVersion::Version00 => Id::v1_from_keys(&keys.keys), + KeySetVersion::Version01 => Id::v2_from_data(&keys.keys, &keys.unit, keys.final_expiry), + } } } @@ -381,7 +631,7 @@ impl From<&MintKeys> for Id { fn from(map: &MintKeys) -> Self { let keys: super::Keys = map.clone().into(); - Id::from(&keys) + Id::v1_from_keys(&keys) } } @@ -391,10 +641,11 @@ mod test { use bitcoin::secp256k1::rand::{self, RngCore}; - use super::{KeySetInfo, Keys, KeysetResponse}; + use super::{KeySetInfo, KeySetVersion, Keys, KeysetResponse, ShortKeysetId}; use crate::nuts::nut02::{Error, Id}; use crate::nuts::KeysResponse; use crate::util::hex; + use crate::CurrencyUnit; const SHORT_KEYSET_ID: &str = "00456a94ab4e1c46"; const SHORT_KEYSET: &str = r#" @@ -482,17 +733,43 @@ mod test { let keys: Keys = serde_json::from_str(SHORT_KEYSET).unwrap(); - let id: Id = (&keys).into(); + let id: Id = Id::v1_from_keys(&keys); assert_eq!(id, Id::from_str(SHORT_KEYSET_ID).unwrap()); let keys: Keys = serde_json::from_str(KEYSET).unwrap(); - let id: Id = (&keys).into(); + let id: Id = Id::v1_from_keys(&keys); assert_eq!(id, Id::from_str(KEYSET_ID).unwrap()); } + #[test] + fn test_v2_deserialization_and_id_generation() { + let unit: CurrencyUnit = CurrencyUnit::from_str("sat").unwrap(); + let expiry: u64 = 2059210353; // +10 years from now + + let keys: Keys = serde_json::from_str(SHORT_KEYSET).unwrap(); + let id_from_str = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + let id = Id::v2_from_data(&keys, &unit, Some(expiry)); + assert_eq!(id, id_from_str); + + let keys: Keys = serde_json::from_str(KEYSET).unwrap(); + let id_from_str = + Id::from_str("0125bc634e270ad7e937af5b957f8396bb627d73f6e1fd2ffe4294c26b57daf9e0") + .unwrap(); + let id = Id::v2_from_data(&keys, &unit, Some(expiry)); + assert_eq!(id, id_from_str); + + let id = Id::v2_from_data(&keys, &unit, None); + let id_from_str = + Id::from_str("016d72f27c8d22808ad66d1959b3dab83af17e2510db7ffd57d2365d9eec3ced75") + .unwrap(); + assert_eq!(id, id_from_str); + } + #[test] fn test_deserialization_keyset_info() { let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#; @@ -519,6 +796,15 @@ mod test { assert_eq!(864559728, id_int) } + #[test] + fn test_v2_to_int() { + let id = Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + + let id_int = u32::from(id); + assert_eq!(2113471806, id_int); + } + #[test] fn test_id_from_invalid_byte_length() { let three_bytes = [0x01, 0x02, 0x03]; @@ -548,16 +834,28 @@ mod test { assert_eq!(keys_response.keysets.len(), 2); } - fn generate_random_id() -> Id { - let mut rand_bytes = vec![0u8; 8]; - rand::thread_rng().fill_bytes(&mut rand_bytes[1..]); - Id::from_bytes(&rand_bytes) - .unwrap_or_else(|e| panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes))) + fn generate_random_id(version: KeySetVersion) -> Id { + match version { + KeySetVersion::Version00 => { + let mut rand_bytes = vec![0u8; 8]; + rand::thread_rng().fill_bytes(&mut rand_bytes[1..]); + Id::from_bytes(&rand_bytes).unwrap_or_else(|e| { + panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes)) + }) + } + KeySetVersion::Version01 => { + let mut rand_bytes = vec![1u8; 33]; + rand::thread_rng().fill_bytes(&mut rand_bytes[1..]); + Id::from_bytes(&rand_bytes).unwrap_or_else(|e| { + panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes)) + }) + } + } } #[test] fn test_id_serialization() { - let id = generate_random_id(); + let id = generate_random_id(KeySetVersion::Version00); let id_str = id.to_string(); assert!(id_str.chars().all(|c| c.is_ascii_hexdigit())); @@ -565,6 +863,16 @@ mod test { assert_eq!(id_str.to_lowercase(), id_str); } + #[test] + fn test_id_v2_serialization() { + let id = generate_random_id(KeySetVersion::Version01); + let id_str = id.to_string(); + + assert!(id_str.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(66, id_str.len()); + assert_eq!(id_str.to_lowercase(), id_str); + } + #[test] fn test_id_deserialization() { let id_from_short_str = Id::from_str("00123"); @@ -579,4 +887,18 @@ mod test { let id_from_uppercase = Id::from_str(&SHORT_KEYSET_ID.to_uppercase()); assert!(id_from_uppercase.is_ok()); } + + #[test] + fn test_short_keyset_id_from_id() { + let idv1 = Id::from_str("009a1f293253e41e").unwrap(); + let idv2 = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + + let short_id_1: ShortKeysetId = idv1.into(); + let short_id_2: ShortKeysetId = idv2.into(); + + assert!(short_id_1.to_string() == "009a1f293253e41e"); + assert!(short_id_2.to_string() == "01adc013fa9d8517"); + } } diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index c03d54b3..0da97609 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -91,7 +91,19 @@ pub async fn pay_request( }, ) .await?; - let proofs = matching_wallet.send(prepared_send, None).await?.proofs(); + + let token = matching_wallet.send(prepared_send, None).await?; + + // We need the keysets information to properly convert from token proof to proof + let keysets_info = match matching_wallet + .localstore + .get_mint_keysets(token.mint_url()?) + .await? + { + Some(keysets_info) => keysets_info, + None => matching_wallet.get_mint_keysets().await?, // Hit the keysets endpoint if we don't have the keysets for this Mint + }; + let proofs = token.proofs(&keysets_info)?; if let Some(transport) = transport { let payload = PaymentRequestPayload { diff --git a/crates/cdk-common/src/database/mint/test.rs b/crates/cdk-common/src/database/mint/test.rs index 4f285af2..5d03fb7c 100644 --- a/crates/cdk-common/src/database/mint/test.rs +++ b/crates/cdk-common/src/database/mint/test.rs @@ -19,7 +19,7 @@ async fn setup_keyset + KeysDatabase>(db: &DB unit: CurrencyUnit::Sat, active: true, valid_from: 0, - valid_to: None, + final_expiry: None, derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), derivation_path_index: Some(0), max_order: 32, diff --git a/crates/cdk-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index a39bb5d0..1195a858 100644 --- a/crates/cdk-common/src/database/wallet.rs +++ b/crates/cdk-common/src/database/wallet.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::fmt::Debug; use async_trait::async_trait; +use cashu::KeySet; use super::Error; use crate::common::ProofInfo; @@ -72,7 +73,7 @@ pub trait Database: Debug { async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err>; /// Add [`Keys`] to storage - async fn add_keys(&self, keys: Keys) -> Result<(), Self::Err>; + async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err>; /// Get [`Keys`] from storage async fn get_keys(&self, id: &Id) -> Result, Self::Err>; /// Remove [`Keys`] from storage diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index f38ed1da..089c3654 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -139,9 +139,6 @@ pub struct MintKeySetInfo { pub active: bool, /// Starting unix time Keyset is valid from pub valid_from: u64, - /// When the Keyset is valid to - /// This is not shown to the wallet and can only be used internally - pub valid_to: Option, /// [`DerivationPath`] keyset pub derivation_path: DerivationPath, /// DerivationPath index of Keyset @@ -151,6 +148,8 @@ pub struct MintKeySetInfo { /// Input Fee ppk #[serde(default = "default_fee")] pub input_fee_ppk: u64, + /// Final expiry + pub final_expiry: Option, } /// Default fee @@ -165,6 +164,7 @@ impl From for KeySetInfo { unit: keyset_info.unit, active: keyset_info.active, input_fee_ppk: keyset_info.input_fee_ppk, + final_expiry: keyset_info.final_expiry, } } } diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 34425caf..fc60de48 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -77,10 +77,11 @@ async fn test_swap_to_send() { ) .await .expect("Failed to send token"); + let keysets_info = wallet_alice.get_mint_keysets().await.unwrap(); + let token_proofs = token.proofs(&keysets_info).unwrap(); assert_eq!( Amount::from(40), - token - .proofs() + token_proofs .total_amount() .expect("Failed to get total amount") ); @@ -92,7 +93,7 @@ async fn test_swap_to_send() { .expect("Failed to get balance") ); assert_eq!( - HashSet::<_, RandomState>::from_iter(token.proofs().ys().expect("Failed to get ys")), + HashSet::<_, RandomState>::from_iter(token_proofs.ys().expect("Failed to get ys")), HashSet::from_iter( wallet_alice .get_pending_spent_proofs() @@ -103,7 +104,8 @@ async fn test_swap_to_send() { ) ); - let transaction_id = TransactionId::from_proofs(token.proofs()).expect("Failed to get tx id"); + let transaction_id = + TransactionId::from_proofs(token_proofs.clone()).expect("Failed to get tx id"); let transaction = wallet_alice .get_transaction(transaction_id) @@ -115,7 +117,7 @@ async fn test_swap_to_send() { assert_eq!(Amount::from(40), transaction.amount); assert_eq!(Amount::from(0), transaction.fee); assert_eq!(CurrencyUnit::Sat, transaction.unit); - assert_eq!(token.proofs().ys().unwrap(), transaction.ys); + assert_eq!(token_proofs.ys().unwrap(), transaction.ys); // Alice sends cashu, Carol receives let wallet_carol = create_test_wallet_for_mint(mint_bob.clone()) @@ -123,7 +125,7 @@ async fn test_swap_to_send() { .expect("Failed to create Carol's wallet"); let received_amount = wallet_carol .receive_proofs( - token.proofs(), + token_proofs.clone(), ReceiveOptions::default(), token.memo().clone(), ) @@ -149,7 +151,7 @@ async fn test_swap_to_send() { assert_eq!(Amount::from(40), transaction.amount); assert_eq!(Amount::from(0), transaction.fee); assert_eq!(CurrencyUnit::Sat, transaction.unit); - assert_eq!(token.proofs().ys().unwrap(), transaction.ys); + assert_eq!(token_proofs.ys().unwrap(), transaction.ys); assert_eq!(token.memo().clone(), transaction.memo); } @@ -237,8 +239,8 @@ async fn test_mint_double_spend() { .await .expect("Could not get proofs"); - let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); + let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); + let keyset_id = keys.id; let preswap = PreMintSecrets::random( keyset_id, @@ -294,8 +296,8 @@ async fn test_attempt_to_swap_by_overflowing() { let amount = 2_u64.pow(63); - let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); + let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); + let keyset_id = keys.id; let pre_mint_amount = PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); @@ -532,7 +534,7 @@ async fn test_swap_overpay_underpay_fee() { .expect("Could not get proofs"); let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); + let keyset_id = Id::v1_from_keys(&keys); let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap(); @@ -597,8 +599,8 @@ async fn test_mint_enforce_fee() { .await .expect("Could not get proofs"); - let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); + let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); + let keyset_id = keys.id; let five_proofs: Vec<_> = proofs.drain(..5).collect(); @@ -884,6 +886,8 @@ async fn test_concurrent_double_spend_melt() { } async fn get_keyset_id(mint: &Mint) -> Id { - let keys = mint.pubkeys().keysets.first().unwrap().clone().keys; - Id::from(&keys) + let keys = mint.pubkeys().keysets.first().unwrap().clone(); + keys.verify_id() + .expect("Keyset ID generation is successful"); + keys.id } diff --git a/crates/cdk-redb/src/wallet/mod.rs b/crates/cdk-redb/src/wallet/mod.rs index 831cb6e0..cdeac6f8 100644 --- a/crates/cdk-redb/src/wallet/mod.rs +++ b/crates/cdk-redb/src/wallet/mod.rs @@ -13,7 +13,8 @@ use cdk_common::mint_url::MintUrl; use cdk_common::util::unix_time; use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; use cdk_common::{ - database, CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State, + database, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, + State, }; use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition}; use tracing::instrument; @@ -493,15 +494,19 @@ impl WalletDatabase for WalletRedbDatabase { } #[instrument(skip_all)] - async fn add_keys(&self, keys: Keys) -> Result<(), Self::Err> { + async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { let write_txn = self.db.begin_write().map_err(Error::from)?; + keyset.verify_id()?; + { let mut table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?; table .insert( - Id::from(&keys).to_string().as_str(), - serde_json::to_string(&keys).map_err(Error::from)?.as_str(), + keyset.id.to_string().as_str(), + serde_json::to_string(&keyset.keys) + .map_err(Error::from)? + .as_str(), ) .map_err(Error::from)?; } diff --git a/crates/cdk-rexie/src/wallet.rs b/crates/cdk-rexie/src/wallet.rs index b5ec623b..20b01895 100644 --- a/crates/cdk-rexie/src/wallet.rs +++ b/crates/cdk-rexie/src/wallet.rs @@ -547,7 +547,10 @@ impl WalletDatabase for WalletRexieDatabase { Ok(()) } - async fn add_keys(&self, keys: Keys) -> Result<(), Self::Err> { + async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { + // Verify ID by recomputing id + keyset.verify_id()?; + let rexie = self.db.lock().await; let transaction = rexie @@ -556,7 +559,7 @@ impl WalletDatabase for WalletRexieDatabase { let keys_store = transaction.store(MINT_KEYS).map_err(Error::from)?; - let keyset_id = serde_wasm_bindgen::to_value(&Id::from(&keys)).map_err(Error::from)?; + let keyset_id = serde_wasm_bindgen::to_value(&keyset.id).map_err(Error::from)?; let keys = serde_wasm_bindgen::to_value(&keys).map_err(Error::from)?; keys_store diff --git a/crates/cdk-signatory/src/common.rs b/crates/cdk-signatory/src/common.rs index 8ca945c5..5459a662 100644 --- a/crates/cdk-signatory/src/common.rs +++ b/crates/cdk-signatory/src/common.rs @@ -68,6 +68,8 @@ pub async fn init_keysets( highest_index_keyset.max_order, highest_index_keyset.unit.clone(), highest_index_keyset.derivation_path.clone(), + highest_index_keyset.final_expiry, + cdk_common::nut02::KeySetVersion::Version00, ); active_keysets.insert(id, keyset); let mut keyset_info = highest_index_keyset; @@ -97,6 +99,8 @@ pub async fn init_keysets( unit.clone(), *max_order, *input_fee_ppk, + // TODO: add Mint settings for a final expiry of newly generated keysets + None, ); let id = keyset_info.id; @@ -114,6 +118,7 @@ pub async fn init_keysets( /// Generate new [`MintKeySetInfo`] from path #[tracing::instrument(skip_all)] +#[allow(clippy::too_many_arguments)] pub fn create_new_keyset( secp: &secp256k1::Secp256k1, xpriv: Xpriv, @@ -122,6 +127,7 @@ pub fn create_new_keyset( unit: CurrencyUnit, max_order: u8, input_fee_ppk: u64, + final_expiry: Option, ) -> (MintKeySet, MintKeySetInfo) { let keyset = MintKeySet::generate( secp, @@ -130,13 +136,16 @@ pub fn create_new_keyset( .expect("RNG busted"), unit, max_order, + final_expiry, + // TODO: change this to Version01 to generate keysets v2 + cdk_common::nut02::KeySetVersion::Version00, ); let keyset_info = MintKeySetInfo { id: keyset.id, unit: keyset.unit.clone(), active: true, valid_from: unix_time(), - valid_to: None, + final_expiry: keyset.final_expiry, derivation_path, derivation_path_index, max_order, diff --git a/crates/cdk-signatory/src/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index b7f87901..834e2309 100644 --- a/crates/cdk-signatory/src/db_signatory.rs +++ b/crates/cdk-signatory/src/db_signatory.rs @@ -72,6 +72,8 @@ impl DbSignatory { unit.clone(), max_order, fee, + // TODO: add and connect settings for this + None, ); let id = keyset_info.id; @@ -130,6 +132,8 @@ impl DbSignatory { keyset_info.max_order, keyset_info.unit.clone(), keyset_info.derivation_path.clone(), + keyset_info.final_expiry, + keyset_info.id.get_version(), ) } } @@ -236,6 +240,8 @@ impl Signatory for DbSignatory { args.unit.clone(), args.max_order, args.input_fee_ppk, + // TODO: add and connect settings for this + None, ); let id = info.id; self.localstore.add_keyset_info(info.clone()).await?; @@ -266,6 +272,8 @@ mod test { 2, CurrencyUnit::Sat, derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), + None, + cdk_common::nut02::KeySetVersion::Version00, ); assert_eq!(keyset.unit, CurrencyUnit::Sat); @@ -310,6 +318,8 @@ mod test { 2, CurrencyUnit::Sat, derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), + None, + cdk_common::nut02::KeySetVersion::Version00, ); assert_eq!(keyset.unit, CurrencyUnit::Sat); diff --git a/crates/cdk-signatory/src/proto/convert.rs b/crates/cdk-signatory/src/proto/convert.rs index e2842cdb..64aa1809 100644 --- a/crates/cdk-signatory/src/proto/convert.rs +++ b/crates/cdk-signatory/src/proto/convert.rs @@ -60,6 +60,7 @@ impl TryInto for KeySet { .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk))) .collect::, _>>()?, ), + final_expiry: self.final_expiry, }) } } @@ -78,6 +79,7 @@ impl From for KeySet { .map(|(key, value)| ((*key).into(), value.to_bytes().to_vec())) .collect(), }), + final_expiry: keyset.final_expiry, } } } @@ -393,6 +395,7 @@ impl TryInto for KeySet { .map(|(k, v)| cdk_common::PublicKey::from_slice(&v).map(|pk| (k.into(), pk))) .collect::, _>>()?, ), + final_expiry: self.final_expiry, }) } } @@ -433,6 +436,7 @@ impl From for KeySet { active: value.active, input_fee_ppk: value.input_fee_ppk, keys: Default::default(), + final_expiry: value.final_expiry, } } } @@ -450,6 +454,7 @@ impl TryInto for KeySet { .map_err(|_| cdk_common::Error::Custom("Invalid unit encoding".to_owned()))?, active: self.active, input_fee_ppk: self.input_fee_ppk, + final_expiry: self.final_expiry, }) } } diff --git a/crates/cdk-signatory/src/proto/signatory.proto b/crates/cdk-signatory/src/proto/signatory.proto index 07576896..7ef6aee5 100644 --- a/crates/cdk-signatory/src/proto/signatory.proto +++ b/crates/cdk-signatory/src/proto/signatory.proto @@ -62,6 +62,7 @@ message KeySet { bool active = 3; uint64 input_fee_ppk = 4; Keys keys = 5; + optional uint64 final_expiry = 6; } message Keys { diff --git a/crates/cdk-signatory/src/signatory.rs b/crates/cdk-signatory/src/signatory.rs index 88de075b..73a66165 100644 --- a/crates/cdk-signatory/src/signatory.rs +++ b/crates/cdk-signatory/src/signatory.rs @@ -73,6 +73,8 @@ pub struct SignatoryKeySet { pub keys: Keys, /// Information about the fee per public key pub input_fee_ppk: u64, + /// Final expiry of the keyset (unix timestamp in the future) + pub final_expiry: Option, } impl From<&SignatoryKeySet> for KeySet { @@ -87,6 +89,7 @@ impl From for KeySet { id: val.id, unit: val.unit, keys: val.keys, + final_expiry: val.final_expiry, } } } @@ -107,7 +110,7 @@ impl From for MintKeySetInfo { derivation_path: Default::default(), derivation_path_index: Default::default(), max_order: 0, - valid_to: None, + final_expiry: val.final_expiry, valid_from: 0, } } @@ -121,6 +124,7 @@ impl From<&(MintKeySetInfo, MintKeySet)> for SignatoryKeySet { active: info.active, input_fee_ppk: info.input_fee_ppk, keys: key.keys.clone().into(), + final_expiry: key.final_expiry, } } } diff --git a/crates/cdk-sqlite/src/mint/auth/mod.rs b/crates/cdk-sqlite/src/mint/auth/mod.rs index b1a8bc7e..9268e065 100644 --- a/crates/cdk-sqlite/src/mint/auth/mod.rs +++ b/crates/cdk-sqlite/src/mint/auth/mod.rs @@ -121,7 +121,7 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { .bind(":unit", keyset.unit.to_string()) .bind(":active", keyset.active) .bind(":valid_from", keyset.valid_from as i64) - .bind(":valid_to", keyset.valid_to.map(|v| v as i64)) + .bind(":valid_to", keyset.final_expiry.map(|v| v as i64)) .bind(":derivation_path", keyset.derivation_path.to_string()) .bind(":max_order", keyset.max_order) .bind(":derivation_path_index", keyset.derivation_path_index) diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index bfe1e021..44ac5a61 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -214,7 +214,7 @@ impl MintKeysDatabase for MintSqliteDatabase { .bind(":unit", keyset.unit.to_string()) .bind(":active", keyset.active) .bind(":valid_from", keyset.valid_from as i64) - .bind(":valid_to", keyset.valid_to.map(|v| v as i64)) + .bind(":valid_to", keyset.final_expiry.map(|v| v as i64)) .bind(":derivation_path", keyset.derivation_path.to_string()) .bind(":max_order", keyset.max_order) .bind(":input_fee_ppk", keyset.input_fee_ppk as i64) @@ -1134,11 +1134,11 @@ fn sqlite_row_to_keyset_info(row: Vec) -> Result unit: column_as_string!(unit, CurrencyUnit::from_str), active: matches!(active, Column::Integer(1)), valid_from: column_as_number!(valid_from), - valid_to: column_as_nullable_number!(valid_to), derivation_path: column_as_string!(derivation_path, DerivationPath::from_str), derivation_path_index: column_as_nullable_number!(derivation_path_index), max_order: column_as_number!(max_order), input_fee_ppk: column_as_number!(row_keyset_ppk), + final_expiry: column_as_nullable_number!(valid_to), }) } @@ -1319,11 +1319,11 @@ mod tests { unit: CurrencyUnit::Sat, active: true, valid_from: 0, - valid_to: None, derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), derivation_path_index: Some(0), max_order: 32, input_fee_ppk: 0, + final_expiry: None, }; db.add_keyset_info(keyset_info).await.unwrap(); @@ -1387,11 +1387,11 @@ mod tests { unit: CurrencyUnit::Sat, active: true, valid_from: 0, - valid_to: None, derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), derivation_path_index: Some(0), max_order: 32, input_fee_ppk: 0, + final_expiry: None, }; db.add_keyset_info(keyset_info).await.unwrap(); diff --git a/crates/cdk-sqlite/src/wallet/migrations.rs b/crates/cdk-sqlite/src/wallet/migrations.rs index 56073e74..dce9e2c9 100644 --- a/crates/cdk-sqlite/src/wallet/migrations.rs +++ b/crates/cdk-sqlite/src/wallet/migrations.rs @@ -16,4 +16,5 @@ pub static MIGRATIONS: &[(&str, &str)] = &[ ("20250314082116_allow_pending_spent.sql", include_str!(r#"./migrations/20250314082116_allow_pending_spent.sql"#)), ("20250323152040_wallet_dleq_proofs.sql", include_str!(r#"./migrations/20250323152040_wallet_dleq_proofs.sql"#)), ("20250401120000_add_transactions_table.sql", include_str!(r#"./migrations/20250401120000_add_transactions_table.sql"#)), + ("20250616144830_add_keyset_expiry.sql", include_str!(r#"./migrations/20250616144830_add_keyset_expiry.sql"#)), ]; diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250616144830_add_keyset_expiry.sql b/crates/cdk-sqlite/src/wallet/migrations/20250616144830_add_keyset_expiry.sql new file mode 100644 index 00000000..3edd4d1f --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20250616144830_add_keyset_expiry.sql @@ -0,0 +1 @@ +ALTER TABLE keyset ADD COLUMN final_expiry INTEGER DEFAULT NULL; \ No newline at end of file diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 9c9014c9..74c57b36 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -14,8 +14,8 @@ use cdk_common::nuts::{MeltQuoteState, MintQuoteState}; use cdk_common::secret::Secret; use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; use cdk_common::{ - database, Amount, CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, Proof, ProofDleq, PublicKey, - SecretKey, SpendingConditions, State, + database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, Proof, ProofDleq, + PublicKey, SecretKey, SpendingConditions, State, }; use error::Error; use tracing::instrument; @@ -294,14 +294,15 @@ ON CONFLICT(mint_url) DO UPDATE SET Statement::new( r#" INSERT INTO keyset - (mint_url, id, unit, active, input_fee_ppk) + (mint_url, id, unit, active, input_fee_ppk, final_expiry) VALUES - (:mint_url, :id, :unit, :active, :input_fee_ppk) + (:mint_url, :id, :unit, :active, :input_fee_ppk, :final_expiry) ON CONFLICT(id) DO UPDATE SET mint_url = excluded.mint_url, unit = excluded.unit, active = excluded.active, - input_fee_ppk = excluded.input_fee_ppk; + input_fee_ppk = excluded.input_fee_ppk, + final_expiry = excluded.final_expiry; "#, ) .bind(":mint_url", mint_url.to_string()) @@ -309,6 +310,7 @@ ON CONFLICT(mint_url) DO UPDATE SET .bind(":unit", keyset.unit.to_string()) .bind(":active", keyset.active) .bind(":input_fee_ppk", keyset.input_fee_ppk as i64) + .bind(":final_expiry", keyset.final_expiry.map(|v| v as i64)) .execute(&conn) .map_err(Error::Sqlite)?; } @@ -327,7 +329,8 @@ ON CONFLICT(mint_url) DO UPDATE SET id, unit, active, - input_fee_ppk + input_fee_ppk, + final_expiry FROM keyset WHERE mint_url = :mint_url @@ -354,7 +357,8 @@ ON CONFLICT(mint_url) DO UPDATE SET id, unit, active, - input_fee_ppk + input_fee_ppk, + final_expiry FROM keyset WHERE id = :id @@ -528,7 +532,10 @@ ON CONFLICT(id) DO UPDATE SET } #[instrument(skip_all)] - async fn add_keys(&self, keys: Keys) -> Result<(), Self::Err> { + async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { + // Recompute ID for verification + keyset.verify_id()?; + Statement::new( r#" INSERT INTO key @@ -539,8 +546,11 @@ ON CONFLICT(id) DO UPDATE SET keys = excluded.keys "#, ) - .bind(":id", Id::from(&keys).to_string()) - .bind(":keys", serde_json::to_string(&keys).map_err(Error::from)?) + .bind(":id", keyset.id.to_string()) + .bind( + ":keys", + serde_json::to_string(&keyset.keys).map_err(Error::from)?, + ) .execute(&self.pool.get().map_err(Error::Pool)?) .map_err(Error::Sqlite)?; @@ -909,13 +919,15 @@ fn sqlite_row_to_mint_info(row: Vec) -> Result { }) } +#[instrument(skip_all)] fn sqlite_row_to_keyset(row: Vec) -> Result { unpack_into!( let ( id, unit, active, - input_fee_ppk + input_fee_ppk, + final_expiry ) = row ); @@ -924,6 +936,7 @@ fn sqlite_row_to_keyset(row: Vec) -> Result { unit: column_as_string!(unit, CurrencyUnit::from_str), active: matches!(active, Column::Integer(1)), input_fee_ppk: column_as_nullable_number!(input_fee_ppk).unwrap_or_default(), + final_expiry: column_as_nullable_number!(final_expiry), }) } diff --git a/crates/cdk/src/mint/keysets/auth.rs b/crates/cdk/src/mint/keysets/auth.rs index 84850245..ef9fb55f 100644 --- a/crates/cdk/src/mint/keysets/auth.rs +++ b/crates/cdk/src/mint/keysets/auth.rs @@ -39,6 +39,7 @@ impl Mint { unit: key.unit.clone(), active: key.active, input_fee_ppk: key.input_fee_ppk, + final_expiry: key.final_expiry, }) } else { None diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index 7e6f62b3..c9a06267 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -53,6 +53,7 @@ impl Mint { unit: k.unit.clone(), active: k.active, input_fee_ppk: k.input_fee_ppk, + final_expiry: k.final_expiry, }) .collect(), } diff --git a/crates/cdk/src/wallet/auth/auth_wallet.rs b/crates/cdk/src/wallet/auth/auth_wallet.rs index 75d25500..fa6718ea 100644 --- a/crates/cdk/src/wallet/auth/auth_wallet.rs +++ b/crates/cdk/src/wallet/auth/auth_wallet.rs @@ -180,7 +180,7 @@ impl AuthWallet { keys.verify_id()?; - self.localstore.add_keys(keys.keys.clone()).await?; + self.localstore.add_keys(keys.clone()).await?; keys.keys }; diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs index c47a6d54..09520194 100644 --- a/crates/cdk/src/wallet/keysets.rs +++ b/crates/cdk/src/wallet/keysets.rs @@ -19,7 +19,7 @@ impl Wallet { keys.verify_id()?; - self.localstore.add_keys(keys.keys.clone()).await?; + self.localstore.add_keys(keys.clone()).await?; keys.keys }; @@ -27,7 +27,23 @@ impl Wallet { Ok(keys) } - /// Get keysets for mint + /// Get keysets from DB or fetch them + /// + /// Checks the database for keysets and queries the Mint if + /// it can't find any. + #[instrument(skip(self))] + pub async fn load_mint_keysets(&self) -> Result, Error> { + match self + .localstore + .get_mint_keysets(self.mint_url.clone()) + .await? + { + Some(keysets_info) => Ok(keysets_info), + None => self.get_mint_keysets().await, // Hit the keysets endpoint if we don't have the keysets for this Mint + } + } + + /// Get keysets for wallet's mint /// /// Queries mint for all keysets #[instrument(skip(self))] diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 7c55f1d1..a0f499ee 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -474,7 +474,7 @@ impl Wallet { /// Can be used to allow a wallet to accept payments offline while reducing /// the risk of claiming back to the limits let by the spending_conditions #[instrument(skip(self, token))] - pub fn verify_token_p2pk( + pub async fn verify_token_p2pk( &self, token: &Token, spending_conditions: SpendingConditions, @@ -526,8 +526,10 @@ impl Wallet { token.mint_url()? ))); } + // We need the keysets information to properly convert from token proof to proof + let keysets_info = self.load_mint_keysets().await?; + let proofs = token.proofs(&keysets_info)?; - let proofs = token.proofs(); for proof in proofs { let secret: nut10::Secret = (&proof.secret).try_into()?; @@ -620,7 +622,9 @@ impl Wallet { // ))); // } - let proofs = token.proofs(); + // We need the keysets information to properly convert from token proof to proof + let keysets_info = self.load_mint_keysets().await?; + let proofs = token.proofs(&keysets_info)?; for proof in proofs { let mint_pubkey = match keys_cache.get(&proof.keyset_id) { Some(keys) => keys.amount_key(proof.amount), diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 8afadfb8..bea3ab3b 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -271,12 +271,6 @@ impl MultiMintWallet { let token_data = Token::from_str(encoded_token)?; let unit = token_data.unit().unwrap_or_default(); - let proofs = token_data.proofs(); - - let mut amount_received = Amount::ZERO; - - let mut mint_errors = None; - let mint_url = token_data.mint_url()?; // Check that all mints in tokes have wallets @@ -291,6 +285,22 @@ impl MultiMintWallet { .get(&wallet_key) .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + // We need the keysets information to properly convert from token proof to proof + let keysets_info = match self + .localstore + .get_mint_keysets(token_data.mint_url()?) + .await? + { + Some(keysets_info) => keysets_info, + // Hit the keysets endpoint if we don't have the keysets for this Mint + None => wallet.get_mint_keysets().await?, + }; + let proofs = token_data.proofs(&keysets_info)?; + + let mut amount_received = Amount::ZERO; + + let mut mint_errors = None; + match wallet .receive_proofs(proofs, opts, token_data.memo().clone()) .await @@ -356,7 +366,7 @@ impl MultiMintWallet { .get(wallet_key) .ok_or(Error::UnknownWallet(wallet_key.clone()))?; - wallet.verify_token_p2pk(token, conditions) + wallet.verify_token_p2pk(token, conditions).await } /// Verifys all proofs in token have valid dleq proof diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index 7a26720e..04afa728 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -219,7 +219,8 @@ impl Wallet { ensure_cdk!(unit == self.unit, Error::UnsupportedUnit); - let proofs = token.proofs(); + let keysets_info = self.load_mint_keysets().await?; + let proofs = token.proofs(&keysets_info)?; if let Token::TokenV3(token) = &token { ensure_cdk!(!token.is_multi_mint(), Error::MultiMintTokenNotSupported);