mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-09 23:26:10 +01:00
feat(wallet): token v4
feat(wallet): receive is single mint and unit
This commit is contained in:
committed by
thesimplekid
parent
4637b050d6
commit
22e7c41491
52
CHANGELOG.md
52
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
|
||||
|
||||
@@ -39,6 +39,7 @@ bitcoin = { version = "0.30", features = [
|
||||
"rand",
|
||||
"rand-std",
|
||||
] } # lightning-invoice uses v0.30
|
||||
anyhow = "1"
|
||||
|
||||
[profile]
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
527
crates/cdk/src/nuts/nut00/token.rs
Normal file
527
crates/cdk/src/nuts/nut00/token.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Amount, Error> {
|
||||
let mut received_proofs: HashMap<UncheckedUrl, Proofs> = 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<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();
|
||||
|
||||
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();
|
||||
|
||||
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<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 unit = token_data.unit().clone().unwrap_or_default();
|
||||
|
||||
let mut received_proofs: HashMap<UncheckedUrl, Proofs> = 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<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();
|
||||
|
||||
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();
|
||||
|
||||
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<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 => {
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user