diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b137891..81cef7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,53 @@ +# Changelog + + + + + + + +## [Unreleased] + +### Summary + +### Changed +cdk(wallet): `wallet:receive` will not claim `proofs` from a mint other then the wallet's mint ([thesimplekid]). +cdk(NUT00): `Token` is changed from a struct to enum of either `TokenV4` or `Tokenv3` ([thesimplekid]). +cdk(NUT00): Rename `MintProofs` to `TokenV3Token` ([thesimplekid]). + + +### Added +cdk: TokenV4 CBOR ([davidcaseria]/[thesimplekid]). +cdk(wallet): `wallet::receive_proof` functions to claim specific proofs instead of encoded token ([thesimplekid]). +cdk-cli: Flag on `send` to print v3 token, default is v4 ([thesimplekid]). + + +## [v0.1.1] + +### Summary + +### Changed +cdk(wallet): `wallet::total_pending_balance` does not include reserced proofs ([thesimplekid]). + + +### Added +cdk(wallet): Added get reserved proofs [thesimplekid](https://github.com/thesimplekid). + + +[thesimplekid]: https://github.com/thesimplekid +[davidcaseria]: https://github.com/davidcaseria diff --git a/Cargo.toml b/Cargo.toml index 8294bfe2..594694e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ bitcoin = { version = "0.30", features = [ "rand", "rand-std", ] } # lightning-invoice uses v0.30 +anyhow = "1" [profile] diff --git a/bindings/cdk-js/src/nuts/nut00/mint_proofs.rs b/bindings/cdk-js/src/nuts/nut00/mint_proofs.rs deleted file mode 100644 index 1dda5065..00000000 --- a/bindings/cdk-js/src/nuts/nut00/mint_proofs.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::ops::Deref; - -use cdk::nuts::MintProofs; -use wasm_bindgen::prelude::*; - -#[wasm_bindgen(js_name = MintProofs)] -pub struct JsMintProofs { - inner: MintProofs, -} - -impl Deref for JsMintProofs { - type Target = MintProofs; - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl From for JsMintProofs { - fn from(inner: MintProofs) -> JsMintProofs { - JsMintProofs { inner } - } -} diff --git a/bindings/cdk-js/src/nuts/nut00/mod.rs b/bindings/cdk-js/src/nuts/nut00/mod.rs index 8bf4510a..17f8d66a 100644 --- a/bindings/cdk-js/src/nuts/nut00/mod.rs +++ b/bindings/cdk-js/src/nuts/nut00/mod.rs @@ -1,7 +1,6 @@ pub mod blind_signature; pub mod blinded_message; pub mod currency_unit; -pub mod mint_proofs; pub mod premint; pub mod proof; pub mod token; diff --git a/crates/cdk-cli/src/sub_commands/receive.rs b/crates/cdk-cli/src/sub_commands/receive.rs index 5e725694..a0f2d7aa 100644 --- a/crates/cdk-cli/src/sub_commands/receive.rs +++ b/crates/cdk-cli/src/sub_commands/receive.rs @@ -127,7 +127,7 @@ async fn receive_token( preimage: &[String], ) -> Result { let token = Token::from_str(token_str)?; - let mint_url = token.token.first().unwrap().mint.clone(); + let mint_url = token.proofs().iter().next().unwrap().0.clone(); let wallet = match wallets.get(&mint_url) { Some(wallet) => wallet.clone(), diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index 9336b7d7..12c841c6 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use anyhow::{bail, Result}; use cdk::amount::SplitTarget; -use cdk::nuts::{Conditions, PublicKey, SpendingConditions}; +use cdk::nuts::{Conditions, PublicKey, SpendingConditions, Token}; use cdk::wallet::Wallet; use cdk::{Amount, UncheckedUrl}; use clap::Args; @@ -32,6 +32,9 @@ pub struct SendSubCommand { /// Refund keys that can be used after locktime #[arg(long, action = clap::ArgAction::Append)] refund_keys: Vec, + /// Token as V3 token + #[arg(short, long)] + v3: bool, } pub async fn send( @@ -152,7 +155,16 @@ pub async fn send( ) .await?; - println!("{}", token); + match sub_command_args.v3 { + true => { + let token = Token::from_str(&token)?; + + println!("{}", token.to_v3_string()); + } + false => { + println!("{}", token); + } + } Ok(()) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 39d98759..e4a9fc1b 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -19,12 +19,13 @@ wallet = ["dep:reqwest"] [dependencies] async-trait.workspace = true base64 = "0.22" # bitcoin uses v0.13 (optional dep) -http = "1.0" bitcoin = { workspace = true, features = [ "serde", "rand", "rand-std", -] } +] } # lightning-invoice uses v0.30 +ciborium = { version = "0.2.2", default-features = false, features = ["std"] } +http = "1.0" lightning-invoice = { version = "0.31", features = ["serde"] } once_cell = "1.19" reqwest = { version = "0.12", default-features = false, features = [ @@ -68,3 +69,4 @@ required-features = ["wallet"] [dev-dependencies] rand = "0.8.5" bip39.workspace = true +anyhow.workspace = true diff --git a/crates/cdk/src/nuts/mod.rs b/crates/cdk/src/nuts/mod.rs index 0e496619..c710ac03 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/crates/cdk/src/nuts/mod.rs @@ -20,8 +20,8 @@ pub mod nut14; pub mod nut15; pub use nut00::{ - BlindSignature, BlindedMessage, CurrencyUnit, MintProofs, PaymentMethod, PreMint, - PreMintSecrets, Proof, Proofs, Token, Witness, + BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, PreMint, PreMintSecrets, Proof, + Proofs, Token, TokenV3, TokenV4, Witness, }; pub use nut01::{Keys, KeysResponse, PublicKey, SecretKey}; #[cfg(feature = "mint")] diff --git a/crates/cdk/src/nuts/nut00.rs b/crates/cdk/src/nuts/nut00/mod.rs similarity index 71% rename from crates/cdk/src/nuts/nut00.rs rename to crates/cdk/src/nuts/nut00/mod.rs index 98bc9231..e2596d4e 100644 --- a/crates/cdk/src/nuts/nut00.rs +++ b/crates/cdk/src/nuts/nut00/mod.rs @@ -5,14 +5,10 @@ use std::cmp::Ordering; use std::fmt; use std::hash::{Hash, Hasher}; -use std::str::FromStr; use std::string::FromUtf8Error; -use base64::engine::{general_purpose, GeneralPurpose}; -use base64::{alphabet, Engine as _}; use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; -use url::Url; use super::nut10; use super::nut11::SpendingConditions; @@ -24,9 +20,11 @@ use crate::nuts::nut12::BlindSignatureDleq; use crate::nuts::nut14::{serde_htlc_witness, HTLCWitness}; use crate::nuts::{Id, ProofDleq}; use crate::secret::Secret; -use crate::url::UncheckedUrl; use crate::Amount; +pub mod token; +pub use token::{Token, TokenV3, TokenV4}; + /// List of [Proof] pub type Proofs = Vec; @@ -54,6 +52,9 @@ pub enum Error { /// Parse Url Error #[error(transparent)] UrlParseError(#[from] url::ParseError), + /// Ciborium error + #[error(transparent)] + CiboriumError(#[from] ciborium::de::Error), /// CDK error #[error(transparent)] Cdk(#[from] crate::error::Error), @@ -233,6 +234,79 @@ impl PartialOrd for Proof { } } +/// Proof V4 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProofV4 { + /// Amount in satoshi + #[serde(rename = "a")] + pub amount: Amount, + /// Secret message + #[serde(rename = "s")] + pub secret: Secret, + /// Unblinded signature + #[serde( + serialize_with = "serialize_v4_pubkey", + deserialize_with = "deserialize_v4_pubkey" + )] + pub c: PublicKey, + /// Witness + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub witness: Option, + /// DLEQ Proof + #[serde(rename = "d")] + pub dleq: Option, +} + +impl ProofV4 { + /// [`ProofV4`] 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 ProofV4 { + fn from(proof: Proof) -> ProofV4 { + let Proof { + amount, + keyset_id: _, + secret, + c, + witness, + dleq, + } = proof; + ProofV4 { + amount, + secret, + c, + witness, + dleq, + } + } +} + +fn serialize_v4_pubkey(key: &PublicKey, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_bytes(&key.to_bytes()) +} + +fn deserialize_v4_pubkey<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let bytes = Vec::::deserialize(deserializer)?; + PublicKey::from_slice(&bytes).map_err(serde::de::Error::custom) +} + /// Currency Unit #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub enum CurrencyUnit { @@ -563,102 +637,6 @@ impl PartialOrd for PreMintSecrets { } } -/// Token -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Token { - /// Proofs in [`Token`] by mint - pub token: Vec, - /// Memo for token - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - /// Token Unit - #[serde(skip_serializing_if = "Option::is_none")] - pub unit: Option, -} - -impl Token { - /// Create new [`Token`] - pub fn new( - mint_url: UncheckedUrl, - proofs: Proofs, - memo: Option, - unit: Option, - ) -> Result { - if proofs.is_empty() { - return Err(Error::ProofsRequired); - } - - // Check Url is valid - let _: Url = (&mint_url).try_into().map_err(|_| Error::InvalidUrl)?; - - Ok(Self { - token: vec![MintProofs::new(mint_url, proofs)], - memo, - unit, - }) - } - - /// Token Info - /// Assumes only one mint in [`Token`] - pub fn token_info(&self) -> (Amount, String) { - let mut amount = Amount::ZERO; - - for proofs in &self.token { - for proof in &proofs.proofs { - amount += proof.amount; - } - } - - (amount, self.token[0].mint.to_string()) - } -} - -impl FromStr for Token { - type Err = Error; - - fn from_str(s: &str) -> Result { - let s = if s.starts_with("cashuA") { - s.replace("cashuA", "") - } else { - return Err(Error::UnsupportedToken); - }; - - let decode_config = general_purpose::GeneralPurposeConfig::new() - .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent); - let decoded = GeneralPurpose::new(&alphabet::STANDARD, decode_config).decode(s)?; - let decoded_str = String::from_utf8(decoded)?; - let token: Token = serde_json::from_str(&decoded_str)?; - Ok(token) - } -} - -impl fmt::Display for Token { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let json_string = serde_json::to_string(self).map_err(|_| fmt::Error)?; - let encoded = general_purpose::STANDARD.encode(json_string); - write!(f, "cashuA{}", encoded) - } -} - -/// Mint Proofs -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MintProofs { - /// Url of mint - pub mint: UncheckedUrl, - /// [`Proofs`] - pub proofs: Proofs, -} - -impl MintProofs { - /// Create new [`MintProofs`] - pub fn new(mint_url: UncheckedUrl, proofs: Proofs) -> Self { - Self { - mint: mint_url, - proofs, - } - } -} - #[cfg(test)] mod tests { use std::str::FromStr; @@ -678,31 +656,8 @@ mod tests { assert_eq!(proof.len(), 2); } - #[test] - fn test_token_str_round_trip() { - let token_str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; - - let token = Token::from_str(token_str).unwrap(); - assert_eq!( - token.token[0].mint, - UncheckedUrl::from_str("https://8333.space:3338").unwrap() - ); - assert_eq!( - token.token[0].proofs[0].clone().keyset_id, - Id::from_str("009a1f293253e41e").unwrap() - ); - assert_eq!(token.unit.clone().unwrap(), CurrencyUnit::Sat); - - let encoded = &token.to_string(); - - let token_data = Token::from_str(encoded).unwrap(); - - assert_eq!(token_data, token); - } - #[test] fn test_blank_blinded_messages() { - // TODO: Need to update id to new type in proof let b = PreMintSecrets::blank( Id::from_str("009a1f293253e41e").unwrap(), Amount::from(1000), @@ -710,30 +665,8 @@ mod tests { .unwrap(); assert_eq!(b.len(), 10); - // TODO: Need to update id to new type in proof let b = PreMintSecrets::blank(Id::from_str("009a1f293253e41e").unwrap(), Amount::from(1)) .unwrap(); assert_eq!(b.len(), 1); } - - #[test] - fn incorrect_tokens() { - let incorrect_prefix = "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; - - let incorrect_prefix_token = Token::from_str(incorrect_prefix); - - assert!(incorrect_prefix_token.is_err()); - - let no_prefix = "eyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; - - let no_prefix_token = Token::from_str(no_prefix); - - assert!(no_prefix_token.is_err()); - - let correct_token = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; - - let correct_token = Token::from_str(correct_token); - - assert!(correct_token.is_ok()); - } } diff --git a/crates/cdk/src/nuts/nut00/token.rs b/crates/cdk/src/nuts/nut00/token.rs new file mode 100644 index 00000000..0b1fd25e --- /dev/null +++ b/crates/cdk/src/nuts/nut00/token.rs @@ -0,0 +1,527 @@ +//! Cashu Token +//! +//! + +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; + +use base64::engine::{general_purpose, GeneralPurpose}; +use base64::{alphabet, Engine as _}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::{Error, Proof, ProofV4, Proofs}; +use crate::nuts::{CurrencyUnit, Id}; +use crate::url::UncheckedUrl; +use crate::Amount; + +/// Token Enum +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Token { + /// Token V3 + TokenV3(TokenV3), + /// Token V4 + TokenV4(TokenV4), +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let token = match self { + Self::TokenV3(token) => token.to_string(), + Self::TokenV4(token) => token.to_string(), + }; + + write!(f, "{}", token) + } +} + +impl Token { + /// Create new [`Token`] + pub fn new( + mint_url: UncheckedUrl, + proofs: Proofs, + memo: Option, + unit: Option, + ) -> Self { + let proofs = proofs + .into_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)) + .collect(); + + Token::TokenV4(TokenV4 { + mint_url, + unit, + memo, + token: proofs, + }) + } + + /// Proofs in [`Token`] + pub fn proofs(&self) -> HashMap { + match self { + Self::TokenV3(token) => token.proofs(), + Self::TokenV4(token) => token.proofs(), + } + } + + /// Total value of [`Token`] + pub fn value(&self) -> Amount { + match self { + Self::TokenV3(token) => token.value(), + Self::TokenV4(token) => token.value(), + } + } + + /// [`Token`] memo + pub fn memo(&self) -> &Option { + match self { + Self::TokenV3(token) => token.memo(), + Self::TokenV4(token) => token.memo(), + } + } + + /// Unit + pub fn unit(&self) -> &Option { + match self { + Self::TokenV3(token) => token.unit(), + Self::TokenV4(token) => token.unit(), + } + } + + /// To v3 string + pub fn to_v3_string(&self) -> String { + let v3_token = match self { + Self::TokenV3(token) => token.clone(), + Self::TokenV4(token) => token.clone().into(), + }; + + v3_token.to_string() + } +} + +impl FromStr for Token { + type Err = Error; + + fn from_str(s: &str) -> Result { + let (is_v3, s) = match (s.strip_prefix("cashuA"), s.strip_prefix("cashuB")) { + (Some(s), None) => (true, s), + (None, Some(s)) => (false, s), + _ => return Err(Error::UnsupportedToken), + }; + + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent); + let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + + match is_v3 { + true => { + let decoded_str = String::from_utf8(decoded)?; + let token: TokenV3 = serde_json::from_str(&decoded_str)?; + Ok(Token::TokenV3(token)) + } + false => { + let token: TokenV4 = ciborium::from_reader(&decoded[..])?; + Ok(Token::TokenV4(token)) + } + } + } +} + +/// Token V3 Token +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenV3Token { + /// Url of mint + pub mint: UncheckedUrl, + /// [`Proofs`] + pub proofs: Proofs, +} + +impl TokenV3Token { + /// Create new [`TokenV3Token`] + pub fn new(mint_url: UncheckedUrl, proofs: Proofs) -> Self { + Self { + mint: mint_url, + proofs, + } + } +} + +/// Token +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenV3 { + /// Proofs in [`Token`] by mint + pub token: Vec, + /// Memo for token + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, + /// Token Unit + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +impl TokenV3 { + /// Create new [`Token`] + pub fn new( + mint_url: UncheckedUrl, + proofs: Proofs, + memo: Option, + unit: Option, + ) -> Result { + if proofs.is_empty() { + return Err(Error::ProofsRequired); + } + + // Check Url is valid + let _: Url = (&mint_url).try_into().map_err(|_| Error::InvalidUrl)?; + + Ok(Self { + token: vec![TokenV3Token::new(mint_url, proofs)], + memo, + unit, + }) + } + + fn proofs(&self) -> HashMap { + let mut proofs: HashMap = HashMap::new(); + + for token in self.token.clone() { + let mint_url = token.mint; + let mut mint_proofs = token.proofs; + + proofs + .entry(mint_url) + .and_modify(|p| p.append(&mut mint_proofs)) + .or_insert(mint_proofs); + } + + proofs + } + + #[inline] + fn value(&self) -> Amount { + self.token + .iter() + .map(|t| t.proofs.iter().map(|p| p.amount).sum()) + .sum() + } + + #[inline] + fn memo(&self) -> &Option { + &self.memo + } + + #[inline] + fn unit(&self) -> &Option { + &self.unit + } +} + +impl FromStr for TokenV3 { + type Err = Error; + + fn from_str(s: &str) -> Result { + let s = s.strip_prefix("cashuA").ok_or(Error::UnsupportedToken)?; + + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent); + let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + let decoded_str = String::from_utf8(decoded)?; + let token: TokenV3 = serde_json::from_str(&decoded_str)?; + Ok(token) + } +} + +impl fmt::Display for TokenV3 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let json_string = serde_json::to_string(self).map_err(|_| fmt::Error)?; + let encoded = general_purpose::URL_SAFE.encode(json_string); + write!(f, "cashuA{}", encoded) + } +} + +impl From for TokenV3 { + fn from(token: TokenV4) -> Self { + let (mint_url, proofs) = token + .proofs() + .into_iter() + .next() + .expect("Token has no proofs"); + TokenV3 { + token: vec![TokenV3Token::new(mint_url, proofs)], + memo: token.memo, + unit: token.unit, + } + } +} + +/// Token V4 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenV4 { + /// Mint Url + #[serde(rename = "m")] + pub mint_url: UncheckedUrl, + /// Token Unit + #[serde(rename = "u", skip_serializing_if = "Option::is_none")] + pub unit: Option, + /// Memo for token + #[serde(rename = "d", skip_serializing_if = "Option::is_none")] + pub memo: Option, + /// Proofs + /// + /// Proofs separated by keyset_id + #[serde(rename = "t")] + pub token: Vec, +} + +impl TokenV4 { + /// Proofs from token + pub fn proofs(&self) -> HashMap { + let mint_url = &self.mint_url; + let mut proofs: HashMap = HashMap::new(); + + for token in self.token.clone() { + let mut mint_proofs = token + .proofs + .iter() + .map(|p| p.into_proof(&token.keyset_id)) + .collect(); + + proofs + .entry(mint_url.clone()) + .and_modify(|p| p.append(&mut mint_proofs)) + .or_insert(mint_proofs); + } + + proofs + } + + #[inline] + fn value(&self) -> Amount { + self.token + .iter() + .map(|t| t.proofs.iter().map(|p| p.amount).sum()) + .sum() + } + + #[inline] + fn memo(&self) -> &Option { + &self.memo + } + + #[inline] + fn unit(&self) -> &Option { + &self.unit + } +} + +impl fmt::Display for TokenV4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use serde::ser::Error; + let mut data = Vec::new(); + ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?; + let encoded = general_purpose::URL_SAFE.encode(data); + write!(f, "cashuB{}", encoded) + } +} + +impl FromStr for TokenV4 { + type Err = Error; + + fn from_str(s: &str) -> Result { + let s = s.strip_prefix("cashuB").ok_or(Error::UnsupportedToken)?; + + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent); + let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + let token: TokenV4 = ciborium::from_reader(&decoded[..])?; + Ok(token) + } +} + +impl TryFrom for TokenV4 { + type Error = Error; + fn try_from(token: TokenV3) -> Result { + let proofs = token.proofs(); + if proofs.len() != 1 { + return Err(Error::UnsupportedToken); + } + + let (mint_url, mint_proofs) = proofs.iter().next().expect("No proofs"); + + let proofs = mint_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)) + .collect(); + + Ok(TokenV4 { + mint_url: mint_url.to_owned(), + token: proofs, + memo: token.memo, + unit: token.unit, + }) + } +} + +/// Token V4 Token +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenV4Token { + /// `Keyset id` + #[serde( + rename = "i", + serialize_with = "serialize_v4_keyset_id", + deserialize_with = "deserialize_v4_keyset_id" + )] + pub keyset_id: Id, + /// Proofs + #[serde(rename = "p")] + pub proofs: Vec, +} + +fn serialize_v4_keyset_id(keyset_id: &Id, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_bytes(&keyset_id.to_bytes()) +} + +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) +} + +impl TokenV4Token { + /// Create new [`TokenV4Token`] + pub fn new(keyset_id: Id, proofs: Proofs) -> Self { + Self { + keyset_id, + proofs: proofs.into_iter().map(|p| p.into()).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + use crate::UncheckedUrl; + + #[test] + fn test_token_v4_str_round_trip() { + let token_str = "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ="; + let token = TokenV4::from_str(token_str).unwrap(); + + assert_eq!( + token.mint_url, + UncheckedUrl::from_str("http://localhost:3338").unwrap() + ); + assert_eq!( + token.token[0].keyset_id, + Id::from_str("00ad268c4d1f5826").unwrap() + ); + + let token: TokenV4 = token.try_into().unwrap(); + + let encoded = &token.to_string(); + + let token_data = TokenV4::from_str(encoded).unwrap(); + + assert_eq!(token_data, token); + } + + #[test] + fn test_token_v4_multi_keyset() -> anyhow::Result<()> { + let token_str_multi_keysets = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA=="; + + let token = Token::from_str(token_str_multi_keysets).unwrap(); + let amount = token.value(); + + assert_eq!(amount, Amount::from(4)); + + let unit = token.unit().clone().unwrap(); + + assert_eq!(CurrencyUnit::Sat, unit); + + match token { + Token::TokenV4(token) => { + let tokens: Vec = token.token.iter().map(|t| t.keyset_id).collect(); + + assert_eq!(tokens.len(), 2); + + assert!(tokens.contains(&Id::from_str("00ffd48b8f5ecf80").unwrap())); + assert!(tokens.contains(&Id::from_str("00ad268c4d1f5826").unwrap())); + + let mint_url = token.mint_url; + + assert_eq!("http://localhost:3338", &mint_url.to_string()); + } + _ => { + anyhow::bail!("Token should be a v4 token") + } + } + + Ok(()) + } + + #[test] + fn test_token_str_round_trip() { + let token_str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; + + let token = TokenV3::from_str(token_str).unwrap(); + assert_eq!( + token.token[0].mint, + UncheckedUrl::from_str("https://8333.space:3338").unwrap() + ); + assert_eq!( + token.token[0].proofs[0].clone().keyset_id, + Id::from_str("009a1f293253e41e").unwrap() + ); + assert_eq!(token.unit.clone().unwrap(), CurrencyUnit::Sat); + + let encoded = &token.to_string(); + + let token_data = TokenV3::from_str(encoded).unwrap(); + + assert_eq!(token_data, token); + } + + #[test] + fn incorrect_tokens() { + let incorrect_prefix = "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; + + let incorrect_prefix_token = TokenV3::from_str(incorrect_prefix); + + assert!(incorrect_prefix_token.is_err()); + + let no_prefix = "eyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; + + let no_prefix_token = TokenV3::from_str(no_prefix); + + assert!(no_prefix_token.is_err()); + + let correct_token = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"; + + let correct_token = TokenV3::from_str(correct_token); + + assert!(correct_token.is_ok()); + } +} diff --git a/crates/cdk/src/wallet/error.rs b/crates/cdk/src/wallet/error.rs index 997877c7..ec2a4655 100644 --- a/crates/cdk/src/wallet/error.rs +++ b/crates/cdk/src/wallet/error.rs @@ -61,6 +61,13 @@ pub enum Error { /// Keyset Not Found #[error("Keyset Not Found")] KeysetNotFound, + /// Receive can only be used with tokens from single mint + #[error("Multiple mint tokens not supported by receive. Please deconstruct the token and use receive with_proof")] + MultiMintTokenNotSupported, + /// Incorrect Mint + /// Token does not match wallet mint + #[error("Token does not match wallet mint")] + IncorrectMint, /// From hex error #[error(transparent)] ReqwestError(#[from] reqwest::Error), diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 6a63d06d..ca096dfd 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -18,11 +18,12 @@ use url::Url; use crate::amount::SplitTarget; use crate::cdk_database::{self, WalletDatabase}; use crate::dhke::{construct_proofs, hash_to_curve}; +use crate::nuts::nut00::token::Token; use crate::nuts::{ nut10, nut12, Conditions, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, Kind, MeltQuoteBolt11Response, MeltQuoteState, MintInfo, MintQuoteBolt11Response, MintQuoteState, PreMintSecrets, PreSwap, Proof, ProofState, Proofs, PublicKey, RestoreRequest, SecretKey, - SigFlag, SpendingConditions, State, SwapRequest, Token, + SigFlag, SpendingConditions, State, SwapRequest, }; use crate::types::{MeltQuote, Melted, MintQuote, ProofInfo}; use crate::url::UncheckedUrl; @@ -891,10 +892,7 @@ impl Wallet { .await?; } - Ok( - util::proof_to_token(mint_url.clone(), send_proofs, memo, Some(unit.clone()))? - .to_string(), - ) + Ok(Token::new(mint_url.clone(), send_proofs, memo, Some(unit.clone())).to_string()) } /// Melt Quote @@ -1214,6 +1212,144 @@ impl Wallet { Ok((condition_selected_proofs, selected_proofs)) } + /// Receive proofs + #[instrument(skip_all)] + pub async fn receive_proofs( + &self, + proofs: Proofs, + amount_split_target: &SplitTarget, + p2pk_signing_keys: &[SecretKey], + preimages: &[String], + ) -> Result { + let mut received_proofs: HashMap = HashMap::new(); + let mint_url = &self.mint_url; + // Add mint if it does not exist in the store + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + self.get_mint_info().await?; + } + + let active_keyset_id = self.active_mint_keyset().await?; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + // Sum amount of all proofs + let amount: Amount = proofs.iter().map(|p| p.amount).sum(); + + let mut proofs = proofs; + + let mut sig_flag = SigFlag::SigInputs; + + // Map hash of preimage to preimage + let hashed_to_preimage: HashMap = preimages + .iter() + .flat_map(|p| match hex::decode(p) { + Ok(hex_bytes) => Some((Sha256Hash::hash(&hex_bytes).to_string(), p)), + Err(_) => None, + }) + .collect(); + + let p2pk_signing_keys: HashMap = p2pk_signing_keys + .iter() + .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) + .collect(); + + for proof in &mut proofs { + // Verify that proof DLEQ is valid + if proof.dleq.is_some() { + let keys = self.get_keyset_keys(proof.keyset_id).await?; + let key = keys.amount_key(proof.amount).ok_or(Error::UnknownKey)?; + proof.verify_dleq(key)?; + } + + if let Ok(secret) = + >::try_into( + proof.secret.clone(), + ) + { + let conditions: Result = + secret.secret_data.tags.unwrap_or_default().try_into(); + if let Ok(conditions) = conditions { + let mut pubkeys = conditions.pubkeys.unwrap_or_default(); + + match secret.kind { + Kind::P2PK => { + let data_key = PublicKey::from_str(&secret.secret_data.data)?; + + pubkeys.push(data_key); + } + Kind::HTLC => { + let hashed_preimage = &secret.secret_data.data; + let preimage = hashed_to_preimage + .get(hashed_preimage) + .ok_or(Error::PreimageNotProvided)?; + proof.add_preimage(preimage.to_string()); + } + } + for pubkey in pubkeys { + if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) { + proof.sign_p2pk(signing.to_owned().clone())?; + } + } + + if conditions.sig_flag.eq(&SigFlag::SigAll) { + sig_flag = SigFlag::SigAll; + } + } + } + } + + let mut pre_swap = self + .create_swap(Some(amount), amount_split_target, proofs, None) + .await?; + + if sig_flag.eq(&SigFlag::SigAll) { + for blinded_message in &mut pre_swap.swap_request.outputs { + for signing_key in p2pk_signing_keys.values() { + blinded_message.sign_p2pk(signing_key.to_owned().clone())? + } + } + } + + let swap_response = self + .client + .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) + .await?; + + // Proof to keep + let p = construct_proofs( + swap_response.signatures, + pre_swap.pre_mint_secrets.rs(), + pre_swap.pre_mint_secrets.secrets(), + &keys, + )?; + let mint_proofs = received_proofs.entry(mint_url.clone()).or_default(); + + self.localstore + .increment_keyset_counter(&active_keyset_id, p.len() as u32) + .await?; + + mint_proofs.extend(p); + + let mut total_amount = Amount::ZERO; + for (mint, proofs) in received_proofs { + total_amount += proofs.iter().map(|p| p.amount).sum(); + let proofs = proofs + .into_iter() + .flat_map(|proof| { + ProofInfo::new(proof, mint.clone(), State::Unspent, self.unit.clone()) + }) + .collect(); + self.localstore.add_proofs(proofs).await?; + } + + Ok(total_amount) + } + /// Receive #[instrument(skip_all)] pub async fn receive( @@ -1223,143 +1359,30 @@ impl Wallet { p2pk_signing_keys: &[SecretKey], preimages: &[String], ) -> Result { - //TODO: check token is for this mint let token_data = Token::from_str(encoded_token)?; - let unit = token_data.unit.unwrap_or_default(); + let unit = token_data.unit().clone().unwrap_or_default(); - let mut received_proofs: HashMap = HashMap::new(); - for token in token_data.token { - if token.proofs.is_empty() { - continue; - } - - // Add mint if it does not exist in the store - if self - .localstore - .get_mint(token.mint.clone()) - .await? - .is_none() - { - self.get_mint_info().await?; - } - - let active_keyset_id = self.active_mint_keyset().await?; - - let keys = self.get_keyset_keys(active_keyset_id).await?; - - // Sum amount of all proofs - let amount: Amount = token.proofs.iter().map(|p| p.amount).sum(); - - let mut proofs = token.proofs; - - let mut sig_flag = SigFlag::SigInputs; - - // Map hash of preimage to preimage - let hashed_to_preimage: HashMap = preimages - .iter() - .flat_map(|p| match hex::decode(p) { - Ok(hex_bytes) => Some((Sha256Hash::hash(&hex_bytes).to_string(), p)), - Err(_) => None, - }) - .collect(); - - let p2pk_signing_keys: HashMap = p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); - - for proof in &mut proofs { - // Verify that proof DLEQ is valid - if proof.dleq.is_some() { - let keys = self.get_keyset_keys(proof.keyset_id).await?; - let key = keys.amount_key(proof.amount).ok_or(Error::UnknownKey)?; - proof.verify_dleq(key)?; - } - - if let Ok(secret) = - >::try_into( - proof.secret.clone(), - ) - { - let conditions: Result = - secret.secret_data.tags.unwrap_or_default().try_into(); - if let Ok(conditions) = conditions { - let mut pubkeys = conditions.pubkeys.unwrap_or_default(); - - match secret.kind { - Kind::P2PK => { - let data_key = PublicKey::from_str(&secret.secret_data.data)?; - - pubkeys.push(data_key); - } - Kind::HTLC => { - let hashed_preimage = &secret.secret_data.data; - let preimage = hashed_to_preimage - .get(hashed_preimage) - .ok_or(Error::PreimageNotProvided)?; - proof.add_preimage(preimage.to_string()); - } - } - for pubkey in pubkeys { - if let Some(signing) = - p2pk_signing_keys.get(&pubkey.x_only_public_key()) - { - proof.sign_p2pk(signing.to_owned().clone())?; - } - } - - if conditions.sig_flag.eq(&SigFlag::SigAll) { - sig_flag = SigFlag::SigAll; - } - } - } - } - - let mut pre_swap = self - .create_swap(Some(amount), amount_split_target, proofs, None) - .await?; - - if sig_flag.eq(&SigFlag::SigAll) { - for blinded_message in &mut pre_swap.swap_request.outputs { - for signing_key in p2pk_signing_keys.values() { - blinded_message.sign_p2pk(signing_key.to_owned().clone())? - } - } - } - - let swap_response = self - .client - .post_swap(token.mint.clone().try_into()?, pre_swap.swap_request) - .await?; - - // Proof to keep - let p = construct_proofs( - swap_response.signatures, - pre_swap.pre_mint_secrets.rs(), - pre_swap.pre_mint_secrets.secrets(), - &keys, - )?; - let mint_proofs = received_proofs.entry(token.mint).or_default(); - - self.localstore - .increment_keyset_counter(&active_keyset_id, p.len() as u32) - .await?; - - mint_proofs.extend(p); + if unit != self.unit { + return Err(Error::UnitNotSupported); } - let mut total_amount = Amount::ZERO; - for (mint, proofs) in received_proofs { - total_amount += proofs.iter().map(|p| p.amount).sum(); - let proofs = proofs - .into_iter() - .flat_map(|proof| ProofInfo::new(proof, mint.clone(), State::Unspent, unit.clone())) - .collect(); - self.localstore.add_proofs(proofs).await?; + let proofs = token_data.proofs(); + if proofs.len() != 1 { + return Err(Error::MultiMintTokenNotSupported); } - Ok(total_amount) + let (mint_url, proofs) = proofs.into_iter().next().expect("Token has proofs"); + + if self.mint_url != mint_url { + return Err(Error::IncorrectMint); + } + + let amount = self + .receive_proofs(proofs, amount_split_target, p2pk_signing_keys, preimages) + .await?; + + Ok(amount) } /// Restore @@ -1526,14 +1549,14 @@ impl Wallet { )); } - for mint_proof in &token.token { - if mint_proof.mint != self.mint_url { + for (mint_url, proofs) in &token.proofs() { + if mint_url != &self.mint_url { return Err(Error::IncorrectWallet(format!( "Should be {} not {}", - self.mint_url, mint_proof.mint + self.mint_url, mint_url ))); } - for proof in &mint_proof.proofs { + for proof in proofs { let secret: nut10::Secret = (&proof.secret).try_into()?; let proof_conditions: SpendingConditions = secret.try_into()?; @@ -1618,14 +1641,14 @@ impl Wallet { pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { let mut keys_cache: HashMap = HashMap::new(); - for mint_proof in &token.token { - if mint_proof.mint != self.mint_url { + for (mint_url, proofs) in &token.proofs() { + if mint_url != &self.mint_url { return Err(Error::IncorrectWallet(format!( "Should be {} not {}", - self.mint_url, mint_proof.mint + self.mint_url, mint_url ))); } - for proof in &mint_proof.proofs { + for proof in proofs { let mint_pubkey = match keys_cache.get(&proof.keyset_id) { Some(keys) => keys.amount_key(proof.amount), None => { diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index ce67c5f7..6e2c976d 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -2,7 +2,7 @@ //! //! Wrapper around core [`Wallet`] that enables the use of multiple mint unit pairs -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fmt; use std::str::FromStr; use std::sync::Arc; @@ -207,33 +207,38 @@ impl MultiMintWallet { preimages: &[String], ) -> Result { let token_data = Token::from_str(encoded_token)?; - let unit = token_data.unit.unwrap_or_default(); - let mint_url = token_data.token.first().unwrap().mint.clone(); + let unit = token_data.unit().clone().unwrap_or_default(); - let mints: HashSet<&UncheckedUrl> = token_data.token.iter().map(|d| &d.mint).collect(); + let mint_proofs = token_data.proofs(); + + let mut amount_received = Amount::ZERO; // Check that all mints in tokes have wallets - for mint in mints { - let wallet_key = WalletKey::new(mint.clone(), unit.clone()); + for (mint_url, proofs) in mint_proofs { + let wallet_key = WalletKey::new(mint_url.clone(), unit.clone()); if !self.has(&wallet_key).await { return Err(Error::UnknownWallet(wallet_key.to_string())); } + + let wallet_key = WalletKey::new(mint_url, unit.clone()); + let wallet = self + .get_wallet(&wallet_key) + .await + .ok_or(Error::UnknownWallet(wallet_key.to_string()))?; + + let amount = wallet + .receive_proofs( + proofs, + &SplitTarget::default(), + p2pk_signing_keys, + preimages, + ) + .await?; + + amount_received += amount; } - let wallet_key = WalletKey::new(mint_url, unit); - let wallet = self - .get_wallet(&wallet_key) - .await - .ok_or(Error::UnknownWallet(wallet_key.to_string()))?; - - wallet - .receive( - encoded_token, - &SplitTarget::default(), - p2pk_signing_keys, - preimages, - ) - .await + Ok(amount_received) } /// Pay an bolt11 invoice from specific wallet diff --git a/crates/cdk/src/wallet/util.rs b/crates/cdk/src/wallet/util.rs index 335a7a7d..39d6de82 100644 --- a/crates/cdk/src/wallet/util.rs +++ b/crates/cdk/src/wallet/util.rs @@ -1,9 +1,5 @@ //! Wallet Utility Functions -use super::Error; -use crate::nuts::{CurrencyUnit, Proofs, Token}; -use crate::UncheckedUrl; - /// Extract token from text pub fn token_from_text(text: &str) -> Option<&str> { let text = text.trim(); @@ -17,16 +13,6 @@ pub fn token_from_text(text: &str) -> Option<&str> { None } -/// Convert proofs to token -pub fn proof_to_token( - mint_url: UncheckedUrl, - proofs: Proofs, - memo: Option, - unit: Option, -) -> Result { - Ok(Token::new(mint_url, proofs, memo, unit)?) -} - #[cfg(test)] mod tests {