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
This commit is contained in:
lollerfirst
2025-09-26 21:56:01 +02:00
committed by GitHub
parent 676463f730
commit 6d0003a4fc
8 changed files with 407 additions and 98 deletions

View File

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

View File

@@ -7,6 +7,7 @@
pub mod database;
pub mod error;
pub mod multi_mint_wallet;
pub mod token;
pub mod types;
pub mod wallet;

View File

@@ -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
crates/cdk-ffi/src/token.rs Normal file
View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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

View File

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

View File

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