From c4389079f3f851b971743127600e83d00ffef554 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Fri, 8 Mar 2024 16:45:13 +0000 Subject: [PATCH] feat: nut13 restore for active keyset --- crates/cashu-sdk/Cargo.toml | 3 +- crates/cashu-sdk/src/client/gloo_client.rs | 30 ++++ crates/cashu-sdk/src/client/minreq_client.rs | 24 +++ crates/cashu-sdk/src/client/mod.rs | 9 ++ crates/cashu-sdk/src/wallet/mod.rs | 151 +++++++++++++++++-- crates/cashu/src/nuts/nut00.rs | 17 ++- crates/cashu/src/nuts/nut09.rs | 14 ++ crates/cashu/src/nuts/nut11.rs | 88 ++++++----- crates/cashu/src/nuts/nut13.rs | 31 ++++ 9 files changed, 308 insertions(+), 59 deletions(-) diff --git a/crates/cashu-sdk/Cargo.toml b/crates/cashu-sdk/Cargo.toml index 90486dd0..4c5e1028 100644 --- a/crates/cashu-sdk/Cargo.toml +++ b/crates/cashu-sdk/Cargo.toml @@ -14,9 +14,10 @@ default = ["mint", "wallet", "all-nuts", "redb"] mint = ["cashu/mint"] wallet = ["cashu/wallet", "dep:minreq", "dep:once_cell"] gloo = ["dep:gloo"] -all-nuts = ["nut07", "nut08", "nut10", "nut11", "nut13"] +all-nuts = ["nut07", "nut08", "nut09", "nut10", "nut11", "nut13"] nut07 = ["cashu/nut07"] nut08 = ["cashu/nut08"] +nut09 = ["cashu/nut09"] nut10 = ["cashu/nut10"] nut11 = ["cashu/nut11"] nut13 = ["cashu/nut13"] diff --git a/crates/cashu-sdk/src/client/gloo_client.rs b/crates/cashu-sdk/src/client/gloo_client.rs index fc19a5a7..166b0674 100644 --- a/crates/cashu-sdk/src/client/gloo_client.rs +++ b/crates/cashu-sdk/src/client/gloo_client.rs @@ -1,6 +1,8 @@ //! gloo wasm http Client use async_trait::async_trait; +#[cfg(feature = "nut09")] +use cashu::nuts::nut09::{RestoreRequest, RestoreResponse}; use cashu::nuts::{ BlindedMessage, MeltBolt11Request, MeltBolt11Response, MintBolt11Request, MintBolt11Response, MintInfo, PreMintSecrets, Proof, SwapRequest, SwapResponse, *, @@ -254,4 +256,32 @@ impl Client for HttpClient { Err(_) => Err(Error::from_json(&res.to_string())?), } } + + /// Restore [NUT-09] + #[cfg(feature = "nut09")] + async fn post_check_state( + &self, + mint_url: Url, + request: RestoreRequest, + ) -> Result { + let url = join_url(mint_url, &["v1", "check"])?; + + let res = Request::post(url.as_str()) + .json(&request) + .map_err(|err| Error::Gloo(err.to_string()))? + .send() + .await + .map_err(|err| Error::Gloo(err.to_string()))? + .json::() + .await + .map_err(|err| Error::Gloo(err.to_string()))?; + + let response: Result = + serde_json::from_value(res.clone()); + + match response { + Ok(res) => Ok(res), + Err(_) => Err(Error::from_json(&res.to_string())?), + } + } } diff --git a/crates/cashu-sdk/src/client/minreq_client.rs b/crates/cashu-sdk/src/client/minreq_client.rs index e4fb858f..77af1232 100644 --- a/crates/cashu-sdk/src/client/minreq_client.rs +++ b/crates/cashu-sdk/src/client/minreq_client.rs @@ -2,6 +2,8 @@ use async_trait::async_trait; use cashu::error::ErrorResponse; +#[cfg(feature = "nut09")] +use cashu::nuts::nut09::{RestoreRequest, RestoreResponse}; #[cfg(feature = "nut07")] use cashu::nuts::PublicKey; use cashu::nuts::{ @@ -221,4 +223,26 @@ impl Client for HttpClient { Err(_) => Err(ErrorResponse::from_json(&res.to_string())?.into()), } } + + #[cfg(feature = "nut09")] + async fn post_restore( + &self, + mint_url: Url, + request: RestoreRequest, + ) -> Result { + let url = join_url(mint_url, &["v1", "restore"])?; + + let res = minreq::post(url) + .with_json(&request)? + .send()? + .json::()?; + + let response: Result = + serde_json::from_value(res.clone()); + + match response { + Ok(res) => Ok(res), + Err(_) => Err(ErrorResponse::from_json(&res.to_string())?.into()), + } + } } diff --git a/crates/cashu-sdk/src/client/mod.rs b/crates/cashu-sdk/src/client/mod.rs index 3d72293a..757ca2d6 100644 --- a/crates/cashu-sdk/src/client/mod.rs +++ b/crates/cashu-sdk/src/client/mod.rs @@ -2,6 +2,8 @@ use async_trait::async_trait; use cashu::error::ErrorResponse; +#[cfg(feature = "nut09")] +use cashu::nuts::nut09::{RestoreRequest, RestoreResponse}; #[cfg(feature = "nut07")] use cashu::nuts::CheckStateResponse; #[cfg(feature = "nut07")] @@ -111,6 +113,13 @@ pub trait Client { ) -> Result; async fn get_mint_info(&self, mint_url: Url) -> Result; + + #[cfg(feature = "nut09")] + async fn post_restore( + &self, + mint_url: Url, + restore_request: RestoreRequest, + ) -> Result; } #[cfg(any(not(target_arch = "wasm32"), feature = "gloo"))] diff --git a/crates/cashu-sdk/src/wallet/mod.rs b/crates/cashu-sdk/src/wallet/mod.rs index cd7fdd14..6c29451a 100644 --- a/crates/cashu-sdk/src/wallet/mod.rs +++ b/crates/cashu-sdk/src/wallet/mod.rs @@ -6,6 +6,9 @@ use bip39::Mnemonic; use cashu::dhke::{construct_proofs, unblind_message}; #[cfg(feature = "nut07")] use cashu::nuts::nut07::ProofState; +use cashu::nuts::nut07::State; +#[cfg(feature = "nut09")] +use cashu::nuts::nut09::RestoreRequest; use cashu::nuts::nut11::SigningKey; #[cfg(feature = "nut07")] use cashu::nuts::PublicKey; @@ -18,7 +21,7 @@ use cashu::url::UncheckedUrl; use cashu::{Amount, Bolt11Invoice}; use localstore::LocalStore; use thiserror::Error; -use tracing::warn; +use tracing::{debug, warn}; use crate::client::Client; use crate::utils::unix_time; @@ -299,8 +302,13 @@ impl Wallet { let count = self .localstore .get_keyset_counter(&active_keyset_id) - .await? - .unwrap_or(0); + .await?; + + let count = if let Some(count) = count { + count + 1 + } else { + 0 + }; counter = Some(count); PreMintSecrets::from_seed( @@ -427,8 +435,14 @@ impl Wallet { let count = self .localstore .get_keyset_counter(&active_keyset_id) - .await? - .unwrap_or(0); + .await?; + + let count = if let Some(count) = count { + count + 1 + } else { + 0 + }; + let premint_secrets = PreMintSecrets::from_seed( active_keyset_id, count, @@ -444,11 +458,11 @@ impl Wallet { PreMintSecrets::random(active_keyset_id, desired_amount)? }; - if let (Some(amt), Some(mnemonic)) = (amount, &self.mnemonic) { + if let Some(amt) = amount { let change_amount = proofs_total - amt; - let change_messages = if let Some(count) = counter { - PreMintSecrets::from_seed(active_keyset_id, count, mnemonic, desired_amount, false)? + let change_messages = if let (Some(count), Some(mnemonic)) = (counter, &self.mnemonic) { + PreMintSecrets::from_seed(active_keyset_id, count, mnemonic, change_amount, false)? } else { PreMintSecrets::random(active_keyset_id, change_amount)? }; @@ -547,6 +561,22 @@ impl Wallet { &self.active_keys(mint_url, unit).await?.unwrap(), )?; + let active_keyset = self.active_mint_keyset(mint_url, unit).await?; + + if self.mnemonic.is_some() { + let count = self + .localstore + .get_keyset_counter(&active_keyset) + .await? + .unwrap_or(0); + + let new_count = count + post_swap_proofs.len() as u64; + + self.localstore + .add_keyset_counter(&active_keyset, new_count) + .await?; + } + post_swap_proofs.reverse(); for proof in post_swap_proofs { @@ -700,8 +730,13 @@ impl Wallet { let count = self .localstore .get_keyset_counter(&active_keyset_id) - .await? - .unwrap_or(0); + .await?; + + let count = if let Some(count) = count { + count + 1 + } else { + 0 + }; counter = Some(count); PreMintSecrets::from_seed(active_keyset_id, count, mnemonic, proofs_amount, true)? @@ -930,6 +965,102 @@ impl Wallet { ) -> Result { Ok(Token::new(mint_url, proofs, memo, unit)?.to_string()) } + + pub async fn restore(&mut self, mint_url: UncheckedUrl) -> Result { + // Check that mint is in store of mints + if self.localstore.get_mint(mint_url.clone()).await?.is_none() { + self.add_mint(mint_url.clone()).await?; + } + + let active_keyset_id = &self + .active_mint_keyset(&mint_url, &CurrencyUnit::Sat) + .await?; + let keys = if let Some(keys) = self.localstore.get_keys(&active_keyset_id).await? { + keys + } else { + self.get_mint_keys(&mint_url, *active_keyset_id).await?; + self.localstore.get_keys(&active_keyset_id).await?.unwrap() + }; + + let mut empty_batch = 0; + let mut start_counter = 0; + let mut restored_value = Amount::ZERO; + + while empty_batch.lt(&3) { + let premint_secrets = PreMintSecrets::restore_batch( + *active_keyset_id, + &self.mnemonic.clone().unwrap(), + start_counter, + start_counter + 100, + )?; + + debug!( + "Attempting to restore counter {}-{} for mint {} keyset {}", + start_counter, + start_counter + 100, + mint_url, + active_keyset_id + ); + + let restore_request = RestoreRequest { + outputs: premint_secrets.blinded_messages(), + }; + + let response = self + .client + .post_restore(mint_url.clone().try_into()?, restore_request) + .await + .unwrap(); + + if response.signatures.is_empty() { + empty_batch += 1; + continue; + } + + let premint_secrets: Vec<_> = premint_secrets + .secrets + .iter() + .filter(|p| response.outputs.contains(&p.blinded_message)) + .collect(); + + // the response outputs and premint secrets should be the same after filtering + // blinded messages the mint did not have signatures for + assert_eq!(response.outputs.len(), premint_secrets.len()); + + let proofs = construct_proofs( + response.signatures, + premint_secrets.iter().map(|p| p.r.clone()).collect(), + premint_secrets.iter().map(|p| p.secret.clone()).collect(), + &keys, + )?; + + self.localstore + .add_keyset_counter(active_keyset_id, start_counter + proofs.len() as u64) + .await?; + + let states = self + .check_proofs_spent(mint_url.clone(), proofs.clone()) + .await?; + + let unspent_proofs: Vec = proofs + .iter() + .zip(states) + .filter(|(_, state)| !state.state.eq(&State::Spent)) + .map(|(p, _)| p) + .cloned() + .collect(); + + restored_value += unspent_proofs.iter().map(|p| p.amount).sum(); + + self.localstore + .add_proofs(mint_url.clone(), unspent_proofs) + .await?; + + empty_batch = 0; + start_counter += 100; + } + Ok(restored_value) + } } /* diff --git a/crates/cashu/src/nuts/nut00.rs b/crates/cashu/src/nuts/nut00.rs index e7516dba..c8332731 100644 --- a/crates/cashu/src/nuts/nut00.rs +++ b/crates/cashu/src/nuts/nut00.rs @@ -31,10 +31,10 @@ pub struct BlindedMessage { /// Witness #[cfg(feature = "nut11")] #[serde(default)] - #[serde(skip_serializing_if = "Signatures::is_empty")] - #[serde(serialize_with = "witness_serialize")] - #[serde(deserialize_with = "witness_deserialize")] - pub witness: Signatures, + #[serde(skip_serializing_if = "Option::is_none")] + //#[serde(serialize_with = "witness_serialize")] + //#[serde(deserialize_with = "witness_deserialize")] + pub witness: Option, } impl BlindedMessage { @@ -44,7 +44,7 @@ impl BlindedMessage { keyset_id, b, #[cfg(feature = "nut11")] - witness: Signatures::default(), + witness: None, } } } @@ -437,11 +437,12 @@ pub struct Proof { pub c: PublicKey, #[cfg(feature = "nut11")] /// Witness + #[cfg(feature = "nut11")] #[serde(default)] - #[serde(skip_serializing_if = "Signatures::is_empty")] + #[serde(skip_serializing_if = "Option::is_none")] #[serde(serialize_with = "witness_serialize")] #[serde(deserialize_with = "witness_deserialize")] - pub witness: Signatures, + pub witness: Option, } impl Proof { @@ -452,7 +453,7 @@ impl Proof { secret, c, #[cfg(feature = "nut11")] - witness: Signatures::default(), + witness: None, } } } diff --git a/crates/cashu/src/nuts/nut09.rs b/crates/cashu/src/nuts/nut09.rs index 5270e640..82e78de4 100644 --- a/crates/cashu/src/nuts/nut09.rs +++ b/crates/cashu/src/nuts/nut09.rs @@ -17,5 +17,19 @@ pub struct RestoreResponse { /// Outputs pub outputs: Vec, /// Signatures + #[serde(rename = "promises")] pub signatures: Vec, } + +mod test { + + #[test] + fn restore_response() { + use super::*; + let rs = r#"{"outputs":[{"B_":"0204bbffa045f28ec836117a29ea0a00d77f1d692e38cf94f72a5145bfda6d8f41","amount":0,"id":"00ffd48b8f5ecf80", "witness":null},{"B_":"025f0615ccba96f810582a6885ffdb04bd57c96dbc590f5aa560447b31258988d7","amount":0,"id":"00ffd48b8f5ecf80"}],"promises":[{"C_":"02e9701b804dc05a5294b5a580b428237a27c7ee1690a0177868016799b1761c81","amount":8,"dleq":null,"id":"00ffd48b8f5ecf80"},{"C_":"031246ee046519b15648f1b8d8ffcb8e537409c84724e148c8d6800b2e62deb795","amount":2,"dleq":null,"id":"00ffd48b8f5ecf80"}]}"#; + + let res: RestoreResponse = serde_json::from_str(rs).unwrap(); + + println!("{:?}", res); + } +} diff --git a/crates/cashu/src/nuts/nut11.rs b/crates/cashu/src/nuts/nut11.rs index d6891407..4075835e 100644 --- a/crates/cashu/src/nuts/nut11.rs +++ b/crates/cashu/src/nuts/nut11.rs @@ -32,14 +32,14 @@ impl Signatures { } } -pub fn witness_serialize(x: &Signatures, s: S) -> Result +pub fn witness_serialize(x: &Option, s: S) -> Result where S: Serializer, { - s.serialize_str(&serde_json::to_string(x).map_err(ser::Error::custom)?) + s.serialize_str(&serde_json::to_string(&x).map_err(ser::Error::custom)?) } -pub fn witness_deserialize<'de, D>(deserializer: D) -> Result +pub fn witness_deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: de::Deserializer<'de>, { @@ -60,22 +60,23 @@ impl Proof { let mut valid_sigs = 0; let msg = &self.secret.to_bytes(); + if let Some(witness) = &self.witness { + for signature in &witness.signatures { + let mut pubkeys = spending_conditions.pubkeys.clone(); + let data_key = VerifyingKey::from_str(&secret.secret_data.data)?; + pubkeys.push(data_key); + for v in &spending_conditions.pubkeys { + let sig = Signature::try_from(hex::decode(signature)?.as_slice())?; - for signature in &self.witness.signatures { - let mut pubkeys = spending_conditions.pubkeys.clone(); - let data_key = VerifyingKey::from_str(&secret.secret_data.data)?; - pubkeys.push(data_key); - for v in &spending_conditions.pubkeys { - let sig = Signature::try_from(hex::decode(signature)?.as_slice())?; - - if v.verify(msg, &sig).is_ok() { - valid_sigs += 1; - } else { - debug!( - "Could not verify signature: {} on message: {}", - hex::encode(sig.to_bytes()), - self.secret.to_string() - ) + if v.verify(msg, &sig).is_ok() { + valid_sigs += 1; + } else { + debug!( + "Could not verify signature: {} on message: {}", + hex::encode(sig.to_bytes()), + self.secret.to_string() + ) + } } } } @@ -84,19 +85,19 @@ impl Proof { return Ok(()); } - println!("{:?}", spending_conditions.refund_keys); - if let Some(locktime) = spending_conditions.locktime { // If lock time has passed check if refund witness signature is valid if locktime.lt(&unix_time()) && !spending_conditions.refund_keys.is_empty() { - for s in &self.witness.signatures { - for v in &spending_conditions.refund_keys { - let sig = Signature::try_from(hex::decode(s)?.as_slice()) - .map_err(|_| Error::InvalidSignature)?; + if let Some(signatures) = &self.witness { + for s in &signatures.signatures { + for v in &spending_conditions.refund_keys { + let sig = Signature::try_from(hex::decode(s)?.as_slice()) + .map_err(|_| Error::InvalidSignature)?; - // As long as there is one valid refund signature it can be spent - if v.verify(msg, &sig).is_ok() { - return Ok(()); + // As long as there is one valid refund signature it can be spent + if v.verify(msg, &sig).is_ok() { + return Ok(()); + } } } } @@ -112,6 +113,8 @@ impl Proof { let signature = secret_key.sign(msg_to_sign); self.witness + .as_mut() + .unwrap_or(&mut Signatures::default()) .signatures .push(hex::encode(signature.to_bytes())); @@ -126,8 +129,11 @@ impl BlindedMessage { let signature = secret_key.sign(&msg_to_sign); self.witness + .as_mut() + .unwrap_or(&mut Signatures::default()) .signatures .push(hex::encode(signature.to_bytes())); + Ok(()) } @@ -137,19 +143,21 @@ impl BlindedMessage { required_sigs: u64, ) -> Result<(), Error> { let mut valid_sigs = 0; - for signature in &self.witness.signatures { - for v in pubkeys { - let msg = &self.b.to_bytes(); - let sig = Signature::try_from(hex::decode(signature)?.as_slice())?; + if let Some(witness) = &self.witness { + for signature in &witness.signatures { + for v in pubkeys { + let msg = &self.b.to_bytes(); + let sig = Signature::try_from(hex::decode(signature)?.as_slice())?; - if v.verify(msg, &sig).is_ok() { - valid_sigs += 1; - } else { - debug!( - "Could not verify signature: {} on message: {}", - hex::encode(sig.to_bytes()), - self.b.to_string() - ) + if v.verify(msg, &sig).is_ok() { + valid_sigs += 1; + } else { + debug!( + "Could not verify signature: {} on message: {}", + hex::encode(sig.to_bytes()), + self.b.to_string() + ) + } } } } @@ -749,7 +757,7 @@ mod tests { "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", ) .unwrap(), - witness: Signatures { signatures: vec![] }, + witness: Some(Signatures { signatures: vec![] }), }; proof.sign_p2pk(secret_key).unwrap(); diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 5097e4f6..7e0a1a3d 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -84,6 +84,37 @@ mod wallet { Ok(pre_mint_secrets) } + + /// Generate blinded messages from predetermined secrets and blindings + /// factor + pub fn restore_batch( + keyset_id: Id, + mnemonic: &Mnemonic, + start_count: u64, + end_count: u64, + ) -> Result { + let mut pre_mint_secrets = PreMintSecrets::default(); + + for i in start_count..end_count { + let secret = Secret::from_seed(mnemonic, keyset_id, i)?; + let blinding_factor = SecretKey::from_seed(mnemonic, keyset_id, i)?; + + let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor.into()))?; + + let blinded_message = BlindedMessage::new(Amount::ZERO, keyset_id, blinded); + + let pre_mint = PreMint { + blinded_message, + secret: secret.clone(), + r: r.into(), + amount: Amount::ZERO, + }; + + pre_mint_secrets.secrets.push(pre_mint); + } + + Ok(pre_mint_secrets) + } } }