Quellcode durchsuchen

token: add spending-condition inspection helpers and token_secrets() (#1124)

* token: add Token::token_secrets() and spending-condition helpers

- New helpers on Token that do not require mint keysets:
  - spending_conditions()
  - p2pk_pubkeys()
  - p2pk_refund_pubkeys()
  - htlc_hashes()
  - locktimes()
- Introduce token_secrets() to unify V3/V4 proof traversal and avoid duplication
- Bypass short->long keyset-id mapping since only Secret is needed for conditions
- Use &Secret for TryFrom to fix compile error
lollerfirst vor 1 Monat
Ursprung
Commit
6d0003a4fc

+ 242 - 2
crates/cashu/src/nuts/nut00/token.rs

@@ -2,18 +2,20 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/00.md>
 
-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<HashSet<SpendingConditions>, 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<HashSet<PublicKey>, Error> {
+        let mut keys: HashSet<PublicKey> = 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<HashSet<PublicKey>, Error> {
+        let mut keys: HashSet<PublicKey> = 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<HashSet<sha256::Hash>, Error> {
+        let mut hashes: HashSet<sha256::Hash> = 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<BTreeSet<u64>, Error> {
+        let mut set: BTreeSet<u64> = 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);
+    }
 }

+ 1 - 0
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;
 

+ 1 - 0
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

+ 158 - 0
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<Self, Self::Err> {
+        let token = cdk::nuts::Token::from_str(s)
+            .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?;
+        Ok(Token { inner: token })
+    }
+}
+
+impl From<cdk::nuts::Token> for Token {
+    fn from(token: cdk::nuts::Token) -> Self {
+        Self { inner: token }
+    }
+}
+
+impl From<Token> 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<Token, FfiError> {
+        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<Amount, FfiError> {
+        Ok(self.inner.value()?.into())
+    }
+
+    /// Get the memo from the token
+    pub fn memo(&self) -> Option<String> {
+        self.inner.memo().clone()
+    }
+
+    /// Get the currency unit
+    pub fn unit(&self) -> Option<CurrencyUnit> {
+        self.inner.unit().map(Into::into)
+    }
+
+    /// Get the mint URL
+    pub fn mint_url(&self) -> Result<MintUrl, FfiError> {
+        Ok(self.inner.mint_url()?.into())
+    }
+
+    /// Get proofs from the token (simplified - no keyset filtering for now)
+    pub fn proofs_simple(&self) -> Result<Proofs, FfiError> {
+        // 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<Vec<u8>, 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<Token, FfiError> {
+        encoded_token.parse()
+    }
+
+    /// Return unique spending conditions across all proofs in this token
+    pub fn spending_conditions(&self) -> Vec<crate::types::SpendingConditions> {
+        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<String> {
+        let set = self
+            .inner
+            .p2pk_pubkeys()
+            .map(|keys| {
+                keys.into_iter()
+                    .map(|k| k.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all refund pubkeys from P2PK spending conditions
+    pub fn p2pk_refund_pubkeys(&self) -> Vec<String> {
+        let set = self
+            .inner
+            .p2pk_refund_pubkeys()
+            .map(|keys| {
+                keys.into_iter()
+                    .map(|k| k.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all HTLC hashes from spending conditions
+    pub fn htlc_hashes(&self) -> Vec<String> {
+        let set = self
+            .inner
+            .htlc_hashes()
+            .map(|hashes| {
+                hashes
+                    .into_iter()
+                    .map(|h| h.to_string())
+                    .collect::<BTreeSet<_>>()
+            })
+            .unwrap_or_default();
+        set.into_iter().collect()
+    }
+
+    /// Return all locktimes from spending conditions (sorted ascending)
+    pub fn locktimes(&self) -> Vec<u64> {
+        self.inner
+            .locktimes()
+            .map(|s| s.into_iter().collect())
+            .unwrap_or_default()
+    }
+}

+ 1 - 92
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<ProofState> 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<Self, Self::Err> {
-        let token = cdk::nuts::Token::from_str(s)
-            .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?;
-        Ok(Token { inner: token })
-    }
-}
-
-impl From<cdk::nuts::Token> for Token {
-    fn from(token: cdk::nuts::Token) -> Self {
-        Self { inner: token }
-    }
-}
-
-impl From<Token> 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<Token, FfiError> {
-        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<Amount, FfiError> {
-        Ok(self.inner.value()?.into())
-    }
-
-    /// Get the memo from the token
-    pub fn memo(&self) -> Option<String> {
-        self.inner.memo().clone()
-    }
-
-    /// Get the currency unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.inner.unit().map(Into::into)
-    }
-
-    /// Get the mint URL
-    pub fn mint_url(&self) -> Result<MintUrl, FfiError> {
-        Ok(self.inner.mint_url()?.into())
-    }
-
-    /// Get proofs from the token (simplified - no keyset filtering for now)
-    pub fn proofs_simple(&self) -> Result<Proofs, FfiError> {
-        // 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<Vec<u8>, 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<Token, FfiError> {
-        encoded_token.parse()
-    }
-}
-
 /// FFI-compatible SendMemo
 #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
 pub struct SendMemo {

+ 1 - 0
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

+ 1 - 1
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));

+ 2 - 3
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);