|
|
@@ -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);
|
|
|
+ }
|
|
|
}
|