diff --git a/crates/cashu/src/nuts/nut00/token.rs b/crates/cashu/src/nuts/nut00/token.rs index f054875f..a9d4bc40 100644 --- a/crates/cashu/src/nuts/nut00/token.rs +++ b/crates/cashu/src/nuts/nut00/token.rs @@ -2,18 +2,20 @@ //! //! -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt; use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine as _}; +use bitcoin::hashes::sha256; use serde::{Deserialize, Serialize}; use super::{Error, Proof, ProofV3, ProofV4, Proofs}; use crate::mint_url::MintUrl; use crate::nut02::ShortKeysetId; -use crate::nuts::{CurrencyUnit, Id}; +use crate::nuts::nut11::SpendingConditions; +use crate::nuts::{CurrencyUnit, Id, Kind, PublicKey}; use crate::{ensure_cdk, Amount, KeySetInfo}; /// Token Enum @@ -128,6 +130,90 @@ impl Token { Self::TokenV4(token) => token.to_raw_bytes(), } } + + /// Return all proof secrets in this token without keyset-id mapping, across V3/V4 + /// This is intended for spending-condition inspection where only the secret matters. + pub fn token_secrets(&self) -> Vec<&crate::secret::Secret> { + match self { + Token::TokenV3(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + Token::TokenV4(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + } + } + + /// Extract unique spending conditions across all proofs + pub fn spending_conditions(&self) -> Result, Error> { + let mut set = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + set.insert(cond); + } + } + Ok(set) + } + + /// Collect pubkeys for P2PK-locked ecash + pub fn p2pk_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.pubkeys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect refund pubkeys from P2PK conditions + pub fn p2pk_refund_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.refund_keys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect HTLC hashes + pub fn htlc_hashes(&self) -> Result, Error> { + let mut hashes: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(SpendingConditions::HTLCConditions { data, .. }) = + SpendingConditions::try_from(secret) + { + hashes.insert(data); + } + } + Ok(hashes) + } + + /// Collect unique locktimes from spending conditions + pub fn locktimes(&self) -> Result, Error> { + let mut set: BTreeSet = BTreeSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if let Some(lt) = cond.locktime() { + set.insert(lt); + } + } + } + Ok(set) + } } impl FromStr for Token { @@ -535,10 +621,13 @@ mod tests { use std::str::FromStr; use bip39::rand::{self, RngCore}; + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; use super::*; use crate::dhke::hash_to_curve; use crate::mint_url::MintUrl; + use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; use crate::secret::Secret; use crate::util::hex; @@ -826,4 +915,155 @@ mod tests { let proofs1 = token1.unwrap().proofs(&keysets_info); assert!(proofs1.is_err()); } + #[test] + fn test_token_spending_condition_helpers_p2pk_htlc_v4() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); + + // P2PK: base pubkey plus an extra pubkey via tags, refund key, and locktime + let sk1 = crate::nuts::SecretKey::generate(); + let pk1 = sk1.public_key(); + let sk2 = crate::nuts::SecretKey::generate(); + let pk2 = sk2.public_key(); + let refund_sk = crate::nuts::SecretKey::generate(); + let refund_pk = refund_sk.public_key(); + + let cond_p2pk = Conditions { + locktime: Some(1_700_000_000), + pubkeys: Some(vec![pk2]), + refund_keys: Some(vec![refund_pk]), + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10_p2pk = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk1.to_string(), + Some(cond_p2pk.clone()), + ); + let secret_p2pk: Secret = nut10_p2pk.try_into().unwrap(); + + // HTLC: use a known preimage hash and its own locktime + let preimage = b"cdk-test-preimage"; + let htlc_hash = Sha256Hash::hash(preimage); + let cond_htlc = Conditions { + locktime: Some(1_800_000_000), + ..Default::default() + }; + let nut10_htlc = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::HTLC, + htlc_hash.to_string(), + Some(cond_htlc.clone()), + ); + let secret_htlc: Secret = nut10_htlc.try_into().unwrap(); + + // Build two proofs (one P2PK, one HTLC) + let proof_p2pk = Proof::new(Amount::from(1), keyset_id, secret_p2pk.clone(), pk1); + let proof_htlc = Proof::new(Amount::from(2), keyset_id, secret_htlc.clone(), pk2); + let token = Token::new( + mint_url, + vec![proof_p2pk, proof_htlc].into_iter().collect(), + None, + CurrencyUnit::Sat, + ); + + // token_secrets should see both + assert_eq!(token.token_secrets().len(), 2); + + // spending_conditions should contain both kinds with their conditions + let sc = token.spending_conditions().unwrap(); + assert!(sc.contains(&SpendingConditions::P2PKConditions { + data: pk1, + conditions: Some(cond_p2pk.clone()) + })); + assert!(sc.contains(&SpendingConditions::HTLCConditions { + data: htlc_hash, + conditions: Some(cond_htlc.clone()) + })); + + // p2pk_pubkeys should include base pk1 and extra pk2 from tags (deduped) + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk1)); + assert!(pks.contains(&pk2)); + assert_eq!(pks.len(), 2); + + // p2pk_refund_pubkeys should include refund_pk only + let refund = token.p2pk_refund_pubkeys().unwrap(); + assert!(refund.contains(&refund_pk)); + assert_eq!(refund.len(), 1); + + // htlc_hashes should include exactly our hash + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.contains(&htlc_hash)); + assert_eq!(hashes.len(), 1); + + // locktimes should include both unique locktimes + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_700_000_000)); + assert!(lts.contains(&1_800_000_000)); + assert_eq!(lts.len(), 2); + } + + #[test] + fn test_token_spending_condition_helpers_dedup_and_v3() { + let mint_url = MintUrl::from_str("https://example.org").unwrap(); + let id = Id::from_str("00ad268c4d1f5826").unwrap(); + + // Same P2PK conditions duplicated across two proofs + let sk = crate::nuts::SecretKey::generate(); + let pk = sk.public_key(); + + let cond = Conditions { + locktime: Some(1_650_000_000), + pubkeys: Some(vec![pk]), // include itself to test dedup inside pubkeys() + refund_keys: Some(vec![pk]), // deliberate duplicate + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10 = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk.to_string(), + Some(cond.clone()), + ); + let secret: Secret = nut10.try_into().unwrap(); + + let p1 = Proof::new(Amount::from(1), id, secret.clone(), pk); + let p2 = Proof::new(Amount::from(2), id, secret.clone(), pk); + + // Build a V3 token explicitly and wrap into Token::TokenV3 + let token_v3 = TokenV3::new( + mint_url, + vec![p1, p2].into_iter().collect(), + None, + Some(CurrencyUnit::Sat), + ) + .unwrap(); + let token = Token::TokenV3(token_v3); + + // Helpers should dedup + let sc = token.spending_conditions().unwrap(); + assert_eq!(sc.len(), 1); // identical conditions across proofs + + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk)); + assert_eq!(pks.len(), 1); // duplicates removed + + let refunds = token.p2pk_refund_pubkeys().unwrap(); + assert!(refunds.contains(&pk)); + assert_eq!(refunds.len(), 1); + + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_650_000_000)); + assert_eq!(lts.len(), 1); + + // No HTLC here + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.is_empty()); + + // token_secrets length equals number of proofs even if conditions identical + assert_eq!(token.token_secrets().len(), 2); + } } diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index fe6ddaba..beb5046f 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -7,6 +7,7 @@ pub mod database; pub mod error; pub mod multi_mint_wallet; +pub mod token; pub mod types; pub mod wallet; diff --git a/crates/cdk-ffi/src/multi_mint_wallet.rs b/crates/cdk-ffi/src/multi_mint_wallet.rs index d8b807fb..6fc72af9 100644 --- a/crates/cdk-ffi/src/multi_mint_wallet.rs +++ b/crates/cdk-ffi/src/multi_mint_wallet.rs @@ -12,6 +12,7 @@ use cdk::wallet::multi_mint_wallet::{ }; use crate::error::FfiError; +use crate::token::Token; use crate::types::*; /// FFI-compatible MultiMintWallet diff --git a/crates/cdk-ffi/src/token.rs b/crates/cdk-ffi/src/token.rs new file mode 100644 index 00000000..31a22e42 --- /dev/null +++ b/crates/cdk-ffi/src/token.rs @@ -0,0 +1,158 @@ +//! FFI token bindings + +use std::collections::BTreeSet; +use std::str::FromStr; + +use crate::error::FfiError; +use crate::{Amount, CurrencyUnit, MintUrl, Proofs}; + +/// FFI-compatible Token +#[derive(Debug, uniffi::Object)] +pub struct Token { + pub(crate) inner: cdk::nuts::Token, +} + +impl std::fmt::Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} + +impl FromStr for Token { + type Err = FfiError; + + fn from_str(s: &str) -> Result { + let token = cdk::nuts::Token::from_str(s) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } +} + +impl From for Token { + fn from(token: cdk::nuts::Token) -> Self { + Self { inner: token } + } +} + +impl From for cdk::nuts::Token { + fn from(token: Token) -> Self { + token.inner + } +} + +#[uniffi::export] +impl Token { + /// Create a new Token from string + #[uniffi::constructor] + pub fn from_string(encoded_token: String) -> Result { + let token = cdk::nuts::Token::from_str(&encoded_token) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } + + /// Get the total value of the token + pub fn value(&self) -> Result { + Ok(self.inner.value()?.into()) + } + + /// Get the memo from the token + pub fn memo(&self) -> Option { + self.inner.memo().clone() + } + + /// Get the currency unit + pub fn unit(&self) -> Option { + self.inner.unit().map(Into::into) + } + + /// Get the mint URL + pub fn mint_url(&self) -> Result { + Ok(self.inner.mint_url()?.into()) + } + + /// Get proofs from the token (simplified - no keyset filtering for now) + pub fn proofs_simple(&self) -> Result { + // For now, return empty keysets to get all proofs + let empty_keysets = vec![]; + let proofs = self.inner.proofs(&empty_keysets)?; + Ok(proofs + .into_iter() + .map(|p| std::sync::Arc::new(p.into())) + .collect()) + } + + /// Convert token to raw bytes + pub fn to_raw_bytes(&self) -> Result, FfiError> { + Ok(self.inner.to_raw_bytes()?) + } + + /// Encode token to string representation + pub fn encode(&self) -> String { + self.to_string() + } + + /// Decode token from string representation + #[uniffi::constructor] + pub fn decode(encoded_token: String) -> Result { + encoded_token.parse() + } + + /// Return unique spending conditions across all proofs in this token + pub fn spending_conditions(&self) -> Vec { + self.inner + .spending_conditions() + .map(|set| set.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + /// Return all P2PK pubkeys referenced by this token's spending conditions + pub fn p2pk_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all refund pubkeys from P2PK spending conditions + pub fn p2pk_refund_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_refund_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all HTLC hashes from spending conditions + pub fn htlc_hashes(&self) -> Vec { + let set = self + .inner + .htlc_hashes() + .map(|hashes| { + hashes + .into_iter() + .map(|h| h.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all locktimes from spending conditions (sorted ascending) + pub fn locktimes(&self) -> Vec { + self.inner + .locktimes() + .map(|s| s.into_iter().collect()) + .unwrap_or_default() + } +} diff --git a/crates/cdk-ffi/src/types.rs b/crates/cdk-ffi/src/types.rs index 9d4b0448..1a6d1d4d 100644 --- a/crates/cdk-ffi/src/types.rs +++ b/crates/cdk-ffi/src/types.rs @@ -10,6 +10,7 @@ use cdk::Amount as CdkAmount; use serde::{Deserialize, Serialize}; use crate::error::FfiError; +use crate::token::Token; /// FFI-compatible Amount type #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] @@ -200,98 +201,6 @@ impl From for CdkState { } } -/// FFI-compatible Token -#[derive(Debug, uniffi::Object)] -pub struct Token { - pub(crate) inner: cdk::nuts::Token, -} - -impl std::fmt::Display for Token { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.inner) - } -} - -impl FromStr for Token { - type Err = FfiError; - - fn from_str(s: &str) -> Result { - let token = cdk::nuts::Token::from_str(s) - .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; - Ok(Token { inner: token }) - } -} - -impl From for Token { - fn from(token: cdk::nuts::Token) -> Self { - Self { inner: token } - } -} - -impl From for cdk::nuts::Token { - fn from(token: Token) -> Self { - token.inner - } -} - -#[uniffi::export] -impl Token { - /// Create a new Token from string - #[uniffi::constructor] - pub fn from_string(encoded_token: String) -> Result { - let token = cdk::nuts::Token::from_str(&encoded_token) - .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; - Ok(Token { inner: token }) - } - - /// Get the total value of the token - pub fn value(&self) -> Result { - Ok(self.inner.value()?.into()) - } - - /// Get the memo from the token - pub fn memo(&self) -> Option { - self.inner.memo().clone() - } - - /// Get the currency unit - pub fn unit(&self) -> Option { - self.inner.unit().map(Into::into) - } - - /// Get the mint URL - pub fn mint_url(&self) -> Result { - Ok(self.inner.mint_url()?.into()) - } - - /// Get proofs from the token (simplified - no keyset filtering for now) - pub fn proofs_simple(&self) -> Result { - // For now, return empty keysets to get all proofs - let empty_keysets = vec![]; - let proofs = self.inner.proofs(&empty_keysets)?; - Ok(proofs - .into_iter() - .map(|p| std::sync::Arc::new(p.into())) - .collect()) - } - - /// Convert token to raw bytes - pub fn to_raw_bytes(&self) -> Result, FfiError> { - Ok(self.inner.to_raw_bytes()?) - } - - /// Encode token to string representation - pub fn encode(&self) -> String { - self.to_string() - } - - /// Decode token from string representation - #[uniffi::constructor] - pub fn decode(encoded_token: String) -> Result { - encoded_token.parse() - } -} - /// FFI-compatible SendMemo #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct SendMemo { diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index b342e142..802a873e 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -7,6 +7,7 @@ use bip39::Mnemonic; use cdk::wallet::{Wallet as CdkWallet, WalletBuilder as CdkWalletBuilder}; use crate::error::FfiError; +use crate::token::Token; use crate::types::*; /// FFI-compatible Wallet diff --git a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs index 44857b9c..8d3b3204 100644 --- a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs +++ b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs @@ -214,7 +214,7 @@ async fn test_ffi_mint_quote_creation() { let quote = wallet .mint_quote(amount, Some(description.clone())) .await - .expect(&format!("Failed to create quote for {} sats", amount_value)); + .unwrap_or_else(|_| panic!("Failed to create quote for {} sats", amount_value)); // Verify quote properties assert_eq!(quote.amount, Some(amount)); diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index d0a60c42..0d89c06c 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -335,10 +335,9 @@ mod test { let db_url = format!("{db_url} schema={test_id}"); - let db = MintPgDatabase::new(db_url.as_str()) + MintPgDatabase::new(db_url.as_str()) .await - .expect("database"); - db + .expect("database") } mint_db_test!(provide_db);