瀏覽代碼

feat(wallet): token v4

feat(wallet): receive is single mint and unit
David Caseria 10 月之前
父節點
當前提交
22e7c41491

+ 52 - 0
CHANGELOG.md

@@ -1 +1,53 @@
+# Changelog
 
+<!-- All notable changes to this project will be documented in this file. -->
+
+<!-- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -->
+<!-- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -->
+
+<!-- Template
+
+## [Unreleased]
+
+### Summary
+
+### Changed
+
+### Added
+
+### Fixed
+
+### Removed
+
+-->
+
+## [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).
+
+<!-- Contributors -->
+[thesimplekid]: https://github.com/thesimplekid
+[davidcaseria]: https://github.com/davidcaseria

+ 1 - 0
Cargo.toml

@@ -39,6 +39,7 @@ bitcoin = { version = "0.30", features = [
     "rand",
     "rand-std",
 ] } # lightning-invoice uses v0.30
+anyhow = "1"
 
 [profile]
 

+ 0 - 22
bindings/cdk-js/src/nuts/nut00/mint_proofs.rs

@@ -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<MintProofs> for JsMintProofs {
-    fn from(inner: MintProofs) -> JsMintProofs {
-        JsMintProofs { inner }
-    }
-}

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

+ 1 - 1
crates/cdk-cli/src/sub_commands/receive.rs

@@ -127,7 +127,7 @@ async fn receive_token(
     preimage: &[String],
 ) -> Result<Amount> {
     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(),

+ 14 - 2
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<String>,
+    /// 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(())
 }

+ 4 - 2
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

+ 2 - 2
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")]

+ 79 - 146
crates/cdk/src/nuts/nut00.rs → 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<Proof>;
 
@@ -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<std::io::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<Witness>,
+    /// DLEQ Proof
+    #[serde(rename = "d")]
+    pub dleq: Option<ProofDleq>,
+}
+
+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<Proof> 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<S>(key: &PublicKey, serializer: S) -> Result<S::Ok, S::Error>
+where
+    S: serde::Serializer,
+{
+    serializer.serialize_bytes(&key.to_bytes())
+}
+
+fn deserialize_v4_pubkey<'de, D>(deserializer: D) -> Result<PublicKey, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let bytes = Vec::<u8>::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<MintProofs>,
-    /// Memo for token
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub memo: Option<String>,
-    /// Token Unit
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub unit: Option<CurrencyUnit>,
-}
-
-impl Token {
-    /// Create new [`Token`]
-    pub fn new(
-        mint_url: UncheckedUrl,
-        proofs: Proofs,
-        memo: Option<String>,
-        unit: Option<CurrencyUnit>,
-    ) -> Result<Self, Error> {
-        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<Self, Self::Err> {
-        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;
@@ -679,30 +657,7 @@ mod tests {
     }
 
     #[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());
-    }
 }

+ 527 - 0
crates/cdk/src/nuts/nut00/token.rs

@@ -0,0 +1,527 @@
+//! Cashu Token
+//!
+//! <https://github.com/cashubtc/nuts/blob/main/00.md>
+
+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<String>,
+        unit: Option<CurrencyUnit>,
+    ) -> Self {
+        let proofs = proofs
+            .into_iter()
+            .fold(HashMap::new(), |mut acc, val| {
+                acc.entry(val.keyset_id)
+                    .and_modify(|p: &mut Vec<Proof>| 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<UncheckedUrl, Proofs> {
+        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<String> {
+        match self {
+            Self::TokenV3(token) => token.memo(),
+            Self::TokenV4(token) => token.memo(),
+        }
+    }
+
+    /// Unit
+    pub fn unit(&self) -> &Option<CurrencyUnit> {
+        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<Self, Self::Err> {
+        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<TokenV3Token>,
+    /// Memo for token
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub memo: Option<String>,
+    /// Token Unit
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub unit: Option<CurrencyUnit>,
+}
+
+impl TokenV3 {
+    /// Create new [`Token`]
+    pub fn new(
+        mint_url: UncheckedUrl,
+        proofs: Proofs,
+        memo: Option<String>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Self, Error> {
+        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<UncheckedUrl, Proofs> {
+        let mut proofs: HashMap<UncheckedUrl, Proofs> = 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<String> {
+        &self.memo
+    }
+
+    #[inline]
+    fn unit(&self) -> &Option<CurrencyUnit> {
+        &self.unit
+    }
+}
+
+impl FromStr for TokenV3 {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        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<TokenV4> 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<CurrencyUnit>,
+    /// Memo for token
+    #[serde(rename = "d", skip_serializing_if = "Option::is_none")]
+    pub memo: Option<String>,
+    /// Proofs
+    ///
+    /// Proofs separated by keyset_id
+    #[serde(rename = "t")]
+    pub token: Vec<TokenV4Token>,
+}
+
+impl TokenV4 {
+    /// Proofs from token
+    pub fn proofs(&self) -> HashMap<UncheckedUrl, Proofs> {
+        let mint_url = &self.mint_url;
+        let mut proofs: HashMap<UncheckedUrl, Proofs> = 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<String> {
+        &self.memo
+    }
+
+    #[inline]
+    fn unit(&self) -> &Option<CurrencyUnit> {
+        &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<Self, Self::Err> {
+        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<TokenV3> for TokenV4 {
+    type Error = Error;
+    fn try_from(token: TokenV3) -> Result<Self, Self::Error> {
+        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<Proof>| 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<ProofV4>,
+}
+
+fn serialize_v4_keyset_id<S>(keyset_id: &Id, serializer: S) -> Result<S::Ok, S::Error>
+where
+    S: serde::Serializer,
+{
+    serializer.serialize_bytes(&keyset_id.to_bytes())
+}
+
+fn deserialize_v4_keyset_id<'de, D>(deserializer: D) -> Result<Id, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let bytes = Vec::<u8>::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<Id> = 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());
+    }
+}

+ 7 - 0
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),

+ 140 - 117
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,147 +1212,137 @@ impl Wallet {
         Ok((condition_selected_proofs, selected_proofs))
     }
 
-    /// Receive
+    /// Receive proofs
     #[instrument(skip_all)]
-    pub async fn receive(
+    pub async fn receive_proofs(
         &self,
-        encoded_token: &str,
+        proofs: Proofs,
         amount_split_target: &SplitTarget,
         p2pk_signing_keys: &[SecretKey],
         preimages: &[String],
     ) -> Result<Amount, Error> {
-        //TODO: check token is for this mint
-        let token_data = Token::from_str(encoded_token)?;
-
-        let unit = token_data.unit.unwrap_or_default();
-
         let mut received_proofs: HashMap<UncheckedUrl, Proofs> = HashMap::new();
-        for token in token_data.token {
-            if token.proofs.is_empty() {
-                continue;
-            }
+        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?;
+        }
 
-            // 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 active_keyset_id = self.active_mint_keyset().await?;
+        let keys = self.get_keyset_keys(active_keyset_id).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();
 
-            // Sum amount of all proofs
-            let amount: Amount = token.proofs.iter().map(|p| p.amount).sum();
+        let mut proofs = proofs;
 
-            let mut proofs = token.proofs;
+        let mut sig_flag = SigFlag::SigInputs;
 
-            let mut sig_flag = SigFlag::SigInputs;
+        // Map hash of preimage to preimage
+        let hashed_to_preimage: HashMap<String, &String> = preimages
+            .iter()
+            .flat_map(|p| match hex::decode(p) {
+                Ok(hex_bytes) => Some((Sha256Hash::hash(&hex_bytes).to_string(), p)),
+                Err(_) => None,
+            })
+            .collect();
 
-            // Map hash of preimage to preimage
-            let hashed_to_preimage: HashMap<String, &String> = 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<XOnlyPublicKey, &SecretKey> = p2pk_signing_keys
+            .iter()
+            .map(|s| (s.x_only_public_key(&SECP256K1).0, s))
+            .collect();
 
-            let p2pk_signing_keys: HashMap<XOnlyPublicKey, &SecretKey> = 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)?;
+            }
 
-            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) =
+                <crate::secret::Secret as TryInto<crate::nuts::nut10::Secret>>::try_into(
+                    proof.secret.clone(),
+                )
+            {
+                let conditions: Result<Conditions, _> =
+                    secret.secret_data.tags.unwrap_or_default().try_into();
+                if let Ok(conditions) = conditions {
+                    let mut pubkeys = conditions.pubkeys.unwrap_or_default();
 
-                if let Ok(secret) =
-                    <crate::secret::Secret as TryInto<crate::nuts::nut10::Secret>>::try_into(
-                        proof.secret.clone(),
-                    )
-                {
-                    let conditions: Result<Conditions, _> =
-                        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());
-                            }
+                    match secret.kind {
+                        Kind::P2PK => {
+                            let data_key = PublicKey::from_str(&secret.secret_data.data)?;
+
+                            pubkeys.push(data_key);
                         }
-                        for pubkey in pubkeys {
-                            if let Some(signing) =
-                                p2pk_signing_keys.get(&pubkey.x_only_public_key())
-                            {
-                                proof.sign_p2pk(signing.to_owned().clone())?;
-                            }
+                        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());
                         }
-
-                        if conditions.sig_flag.eq(&SigFlag::SigAll) {
-                            sig_flag = SigFlag::SigAll;
+                    }
+                    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?;
+        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())?
-                    }
+        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?;
+        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(token.mint).or_default();
+        // 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?;
+        self.localstore
+            .increment_keyset_counter(&active_keyset_id, p.len() as u32)
+            .await?;
 
-            mint_proofs.extend(p);
-        }
+        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, unit.clone()))
+                .flat_map(|proof| {
+                    ProofInfo::new(proof, mint.clone(), State::Unspent, self.unit.clone())
+                })
                 .collect();
             self.localstore.add_proofs(proofs).await?;
         }
@@ -1362,6 +1350,41 @@ impl Wallet {
         Ok(total_amount)
     }
 
+    /// Receive
+    #[instrument(skip_all)]
+    pub async fn receive(
+        &self,
+        encoded_token: &str,
+        amount_split_target: &SplitTarget,
+        p2pk_signing_keys: &[SecretKey],
+        preimages: &[String],
+    ) -> Result<Amount, Error> {
+        let token_data = Token::from_str(encoded_token)?;
+
+        let unit = token_data.unit().clone().unwrap_or_default();
+
+        if unit != self.unit {
+            return Err(Error::UnitNotSupported);
+        }
+
+        let proofs = token_data.proofs();
+        if proofs.len() != 1 {
+            return Err(Error::MultiMintTokenNotSupported);
+        }
+
+        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
     #[instrument(skip(self))]
     pub async fn restore(&self) -> Result<Amount, Error> {
@@ -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<Id, Keys> = 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 => {

+ 25 - 20
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<Amount, Error> {
         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);
-        let wallet = self
-            .get_wallet(&wallet_key)
-            .await
-            .ok_or(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;
+        }
 
-        wallet
-            .receive(
-                encoded_token,
-                &SplitTarget::default(),
-                p2pk_signing_keys,
-                preimages,
-            )
-            .await
+        Ok(amount_received)
     }
 
     /// Pay an bolt11 invoice from specific wallet

+ 0 - 14
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<String>,
-    unit: Option<CurrencyUnit>,
-) -> Result<Token, Error> {
-    Ok(Token::new(mint_url, proofs, memo, unit)?)
-}
-
 #[cfg(test)]
 mod tests {