diff --git a/.github/workflows/wasm_release.yml b/.github/workflows/wasm_release.yml index 56ebc50f..83061eec 100644 --- a/.github/workflows/wasm_release.yml +++ b/.github/workflows/wasm_release.yml @@ -49,7 +49,7 @@ jobs: - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.86.0 + toolchain: 1.87.0 - name: Install wasm-pack run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a2a98c..f1b187ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Call mint endpoint for cancelling * Use mint nostr relays from network and fall back to identity ones * Add endpoints to accept, or reject an offer from a mint + * Add logic to check keyset info, mint and create proofs # 0.3.13 diff --git a/crates/bcr-ebill-api/Cargo.toml b/crates/bcr-ebill-api/Cargo.toml index ae4fd846..f3bdb947 100644 --- a/crates/bcr-ebill-api/Cargo.toml +++ b/crates/bcr-ebill-api/Cargo.toml @@ -31,9 +31,11 @@ bcr-ebill-transport = { path = "../bcr-ebill-transport" } tokio.workspace = true tokio_with_wasm.workspace = true secp256k1.workspace = true -bcr-wdc-webapi = { git = "https://github.com/BitcreditProtocol/wildcat", rev = "8a07e1d5012255282acde35982762f18e80322fb" } -bcr-wdc-quote-client = { git = "https://github.com/BitcreditProtocol/wildcat", rev = "8a07e1d5012255282acde35982762f18e80322fb" } +bcr-wdc-webapi = { git = "https://github.com/BitcreditProtocol/wildcat", rev = "4437c3b809105df69ed459a9698cac8deb8d9210" } +bcr-wdc-quote-client = { git = "https://github.com/BitcreditProtocol/wildcat", rev = "4437c3b809105df69ed459a9698cac8deb8d9210" } +bcr-wdc-key-client = { git = "https://github.com/BitcreditProtocol/wildcat", rev = "4437c3b809105df69ed459a9698cac8deb8d9210" } cashu = { version = "0.9", default-features = false } +rand = { version = "0.8" } [target.'cfg(target_arch = "wasm32")'.dependencies] reqwest = { workspace = true, features = ["json"] } diff --git a/crates/bcr-ebill-api/src/constants.rs b/crates/bcr-ebill-api/src/constants.rs index 97c8dae8..c448d3b5 100644 --- a/crates/bcr-ebill-api/src/constants.rs +++ b/crates/bcr-ebill-api/src/constants.rs @@ -5,3 +5,5 @@ pub const VALID_FILE_MIME_TYPES: [&str; 3] = ["image/jpeg", "image/png", "applic // When subscribing events we subtract this from the last received event time pub const NOSTR_EVENT_TIME_SLACK: u64 = 3600; // 1 hour +pub const CURRENCY_CRSAT: &str = "crsat"; +pub const CURRENCY_SAT: &str = "sat"; diff --git a/crates/bcr-ebill-api/src/external/mint.rs b/crates/bcr-ebill-api/src/external/mint.rs index b7871f55..f54d940b 100644 --- a/crates/bcr-ebill-api/src/external/mint.rs +++ b/crates/bcr-ebill-api/src/external/mint.rs @@ -7,9 +7,10 @@ use bcr_ebill_core::{ contact::{BillAnonParticipant, BillIdentParticipant, BillParticipant, ContactType}, util::{BcrKeys, date::DateTimeUtc}, }; +use bcr_wdc_key_client::KeyClient; use bcr_wdc_quote_client::QuoteClient; use bcr_wdc_webapi::quotes::{BillInfo, ResolveOffer, StatusReply}; -use cashu::{nut01 as cdk01, nut02 as cdk02}; +use cashu::{nut00 as cdk00, nut01 as cdk01, nut02 as cdk02}; use thiserror::Error; /// Generic result type @@ -24,6 +25,9 @@ pub enum Error { /// all errors originating from parsing public keys #[error("External Mint Public Key Error")] PubKey, + /// all errors originating from parsing private keys + #[error("External Mint Private Key Error")] + PrivateKey, /// all errors originating from creating signatures #[error("External Mint Signature Error")] Signature, @@ -36,20 +40,40 @@ pub enum Error { /// all errors originating from invalid mint request ids #[error("External Mint Invalid Mint Request Id Error")] InvalidMintRequestId, + /// all errors originating from invalid keyset ids + #[error("External Mint Invalid KeySet Id Error")] + InvalidKeySetId, + /// all errors originating from blind message generation + #[error("External Mint BlindMessage Error")] + BlindMessage, /// all errors originating from the quote client #[error("External Mint Quote Client Error")] QuoteClient, + /// all errors originating from the key client + #[error("External Mint Key Client Error")] + KeyClient, } #[cfg(test)] use mockall::automock; -use crate::util; +use crate::{constants::CURRENCY_CRSAT, util}; #[cfg_attr(test, automock)] #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait MintClientApi: ServiceTraitBounds { + /// Mint and return encoded token + async fn mint( + &self, + mint_url: &str, + keyset: cdk02::KeySet, + discounted_amount: u64, + quote_id: &str, + private_key: &str, + ) -> Result; + /// Check keyset info for a given keyset id with a given mint + async fn get_keyset_info(&self, mint_url: &str, keyset_id: &str) -> Result; /// Request to mint a bill with a given mint async fn enquire_mint_quote( &self, @@ -94,11 +118,81 @@ impl MintClient { ); Ok(quote_client) } + + pub fn key_client(&self, mint_url: &str) -> Result { + let key_client = bcr_wdc_key_client::KeyClient::new( + reqwest::Url::parse(mint_url).map_err(|_| Error::InvalidMintUrl)?, + ); + Ok(key_client) + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl MintClientApi for MintClient { + async fn mint( + &self, + mint_url: &str, + keyset: cdk02::KeySet, + discounted_amount: u64, + quote_id: &str, + private_key: &str, + ) -> Result { + let secret_key = cdk01::SecretKey::from_hex(private_key).map_err(|_| Error::PrivateKey)?; + let qid = uuid::Uuid::from_str(quote_id).map_err(|_| Error::InvalidMintRequestId)?; + + // create blinded messages + let amounts: Vec = cashu::Amount::from(discounted_amount).split(); + let blinds = generate_blinds(keyset.id, &amounts)?; + let blinded_messages = blinds.iter().map(|b| b.0.clone()).collect::>(); + + // mint + let blinded_signatures = self + .key_client(mint_url)? + .mint(qid, blinded_messages, secret_key) + .await + .map_err(|e| { + log::error!("Error minting at mint {mint_url}: {e}"); + Error::KeyClient + })?; + + // create proofs + let secrets = blinds.iter().map(|b| b.1.clone()).collect::>(); + let rs = blinds.iter().map(|b| b.2.clone()).collect::>(); + let proofs = + cashu::dhke::construct_proofs(blinded_signatures, rs, secrets, &keyset.keys).unwrap(); + + // generate token from proofs + let mint_url = cashu::MintUrl::from_str(mint_url).map_err(|_| Error::InvalidMintUrl)?; + let token = cdk00::Token::new( + mint_url, + proofs, + None, + cashu::CurrencyUnit::Custom(CURRENCY_CRSAT.into()), + ); + + Ok(token.to_v3_string()) + } + + async fn get_keyset_info(&self, mint_url: &str, keyset_id: &str) -> Result { + let base = reqwest::Url::parse(mint_url).map_err(|_| Error::InvalidMintUrl)?; + let url = base + .join(&format!("/v1/keys/{}", keyset_id)) + .expect("keys relative path"); + let res = reqwest::Client::new().get(url).send().await.map_err(|e| { + log::error!("Error getting keyset info from mint {mint_url}: {e}"); + Error::KeyClient + })?; + let json: cdk01::KeysResponse = res.json().await.map_err(|e| { + log::error!("Error deserializing keyset info: {e}"); + Error::KeyClient + })?; + json.keysets.first().map(|k| k.to_owned()).ok_or_else(|| { + log::error!("Empty keyset"); + Error::KeyClient.into() + }) + } + async fn enquire_mint_quote( &self, mint_url: &str, @@ -180,6 +274,38 @@ impl MintClientApi for MintClient { } } +pub fn generate_blinds( + keyset_id: cashu::Id, + amounts: &[cashu::Amount], +) -> Result< + Vec<( + cashu::BlindedMessage, + cashu::secret::Secret, + cashu::SecretKey, + )>, +> { + let mut blinds = Vec::new(); + for amount in amounts { + let blind = generate_blind(keyset_id, *amount)?; + blinds.push(blind); + } + Ok(blinds) +} + +pub fn generate_blind( + kid: cashu::Id, + amount: cashu::Amount, +) -> Result<( + cashu::BlindedMessage, + cashu::secret::Secret, + cashu::SecretKey, +)> { + let secret = cashu::secret::Secret::new(rand::random::().to_string()); + let (b_, r) = + cashu::dhke::blind_message(secret.as_bytes(), None).map_err(|_| Error::BlindMessage)?; + Ok((cashu::BlindedMessage::new(amount, kid, b_), secret, r)) +} + #[derive(Debug, Clone)] pub enum ResolveMintOffer { Accept, diff --git a/crates/bcr-ebill-api/src/service/bill_service/service.rs b/crates/bcr-ebill-api/src/service/bill_service/service.rs index 3ca2da3d..0d5f5bbf 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/service.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/service.rs @@ -3,6 +3,7 @@ use super::{BillAction, BillServiceApi, Result}; use crate::blockchain::Blockchain; use crate::blockchain::bill::block::BillIdentParticipantBlockData; use crate::blockchain::bill::{BillBlockchain, BillOpCode}; +use crate::constants::CURRENCY_SAT; use crate::data::{ File, bill::{ @@ -31,6 +32,7 @@ use bcr_ebill_core::bill::{ PastPaymentDataSell, PastPaymentResult, PastPaymentStatus, }; use bcr_ebill_core::blockchain::bill::block::{BillParticipantBlockData, NodeId}; +use bcr_ebill_core::company::{Company, CompanyKeys}; use bcr_ebill_core::constants::{ ACCEPT_DEADLINE_SECONDS, PAYMENT_DEADLINE_SECONDS, RECOURSE_DEADLINE_SECONDS, }; @@ -286,18 +288,93 @@ impl BillService { "Checking mint request for quote {}", &mint_request.mint_request_id ); - // if it doesn't have a 'finished' state, we check the quote at the mint - if !matches!( - mint_request.status, - MintRequestStatus::Cancelled { .. } - | MintRequestStatus::Rejected { .. } - | MintRequestStatus::Expired { .. } - | MintRequestStatus::Denied { .. } - | MintRequestStatus::Accepted - ) { - let mint_cfg = &get_config().mint_config; - // for now, we only support the default mint - if mint_request.mint_node_id == mint_cfg.default_mint_node_id { + let mint_cfg = &get_config().mint_config; + // for now, we only support the default mint + if mint_request.mint_node_id != mint_cfg.default_mint_node_id { + return Ok(()); + } + + match mint_request.status { + // If it's accepted, get the offer and, if it's not finished (i.e. has no proofs), attempt to get keyset and mint + MintRequestStatus::Accepted => { + if let Ok(Some(offer)) = self + .mint_store + .get_offer(&mint_request.mint_request_id) + .await + { + if offer.proofs.is_none() { + debug!( + "Checking for keyset info for {}", + &mint_request.mint_request_id + ); + // not finished - check keyset and try to mint and create tokens and persist + match self + .mint_client + .get_keyset_info(&mint_cfg.default_mint_url, &offer.keyset_id) + .await + { + // keyset info is available + Ok(keyset_info) => { + // fetch private key for requester + let private_key = match self.identity_store.get_full().await { + Ok(identity) => { + // check if requester is identity + if identity.identity.node_id + == mint_request.requester_node_id + { + identity.key_pair.get_private_key_string() + } else { + // check if requester is a company + let local_companies: HashMap< + String, + (Company, CompanyKeys), + > = self.company_store.get_all().await?; + if let Some(requester_company) = + local_companies.get(&mint_request.requester_node_id) + { + requester_company.1.private_key.clone() + } else { + // requester is neither identity, nor company + log::warn!( + "Requester for {} is not a local identity, or company", + &mint_request.mint_request_id + ); + return Ok(()); + } + } + } + Err(e) => { + return Err(e.into()); + } + }; + debug!( + "Keyset found and minting for {}", + &mint_request.mint_request_id + ); + // mint and generate proofs + let proofs = self + .mint_client + .mint( + &mint_cfg.default_mint_url, + keyset_info, + offer.discounted_sum, + &mint_request.mint_request_id, + &private_key, + ) + .await?; + // store proofs on the offer + self.mint_store + .add_proofs_to_offer(&mint_request.mint_request_id, &proofs) + .await?; + } + Err(_) => { + info!("No keyset available for {}", mint_request.mint_request_id); + } + }; + } + } + } + MintRequestStatus::Pending | MintRequestStatus::Offered => { let updated_status = self .mint_client .lookup_quote_for_mint( @@ -308,17 +385,35 @@ impl BillService { // only update, if changed match updated_status { QuoteStatusReply::Pending => { - if !matches!(mint_request.status, MintRequestStatus::Pending) { + // it's already pending, or offered and can't go from Offered to Pending - nothing to do + } + QuoteStatusReply::Offered { + keyset_id, + expiration_date, + discounted, + } => { + // if it's not already offered, set to offered + if !matches!(mint_request.status, MintRequestStatus::Offered) { + // Update the request self.mint_store .update_request( &mint_request.mint_request_id, - &MintRequestStatus::Pending, + &MintRequestStatus::Offered, + ) + .await?; + // Store the offer + self.mint_store + .add_offer( + &mint_request.mint_request_id, + &keyset_id.to_string(), + expiration_date.timestamp() as u64, + discounted.to_sat(), ) .await?; } } QuoteStatusReply::Denied { tstamp } => { - // checked above, that it's not denied + // checked below, that it's not denied self.mint_store .update_request( &mint_request.mint_request_id, @@ -329,7 +424,7 @@ impl BillService { .await?; } QuoteStatusReply::Expired { tstamp } => { - // checked above, that it's not expired + // checked below, that it's not expired self.mint_store .update_request( &mint_request.mint_request_id, @@ -340,7 +435,7 @@ impl BillService { .await?; } QuoteStatusReply::Cancelled { tstamp } => { - // checked above, that it's not cancelled + // checked below, that it's not cancelled self.mint_store .update_request( &mint_request.mint_request_id, @@ -350,53 +445,33 @@ impl BillService { ) .await?; } - QuoteStatusReply::Offered { - keyset_id, - expiration_date, - discounted, - } => { - if !matches!(mint_request.status, MintRequestStatus::Offered) { - // Update the request - self.mint_store - .update_request( - &mint_request.mint_request_id, - &MintRequestStatus::Offered, - ) - .await?; - // Store the offer - self.mint_store - .add_offer( - &mint_request.mint_request_id, - &keyset_id.to_string(), - expiration_date.timestamp() as u64, - discounted.to_sat(), - ) - .await?; - } - } - QuoteStatusReply::Accepted { .. } => { - // checked above, that it's not accepted + QuoteStatusReply::Rejected { tstamp } => { + // checked below, that it's not rejected self.mint_store .update_request( &mint_request.mint_request_id, - &MintRequestStatus::Accepted, + &MintRequestStatus::Rejected { + timestamp: tstamp.timestamp() as u64, + }, ) .await?; } - QuoteStatusReply::Rejected { tstamp } => { - // checked above, that it's not rejected + QuoteStatusReply::Accepted { .. } => { + // checked above, that it's not accepted self.mint_store .update_request( &mint_request.mint_request_id, - &MintRequestStatus::Rejected { - timestamp: tstamp.timestamp() as u64, - }, + &MintRequestStatus::Accepted, ) .await?; } }; } - } + // Cancelled, Rejected, Expired, Denied + _ => { + // Req to mint is finished - nothing to do + } + }; Ok(()) } @@ -1408,10 +1483,13 @@ impl BillServiceApi for BillService { } async fn check_mint_state_for_all_bills(&self) -> Result<()> { - debug!("checking all active mint requests"); - // get all active (offered, pending) requests + debug!("checking all not-finished mint requests"); + // get all not-finished (offered, pending, accepted) requests let requests = self.mint_store.get_all_active_requests().await?; - debug!("checking all active mint requests ({})", requests.len()); + debug!( + "checking all not-finished mint requests ({})", + requests.len() + ); for req in requests { if let Err(e) = self.check_mint_quote_and_update_bill_mint_state(&req).await { error!( @@ -1430,7 +1508,7 @@ impl BillServiceApi for BillService { signer_keys: &BcrKeys, timestamp: u64, ) -> Result<()> { - let currency = "sat".to_string(); // default to sat for now + let currency = CURRENCY_SAT.to_string(); // default to sat for now let identity = self.identity_store.get().await?; debug!("trying to accept offer from request to mint {mint_request_id}"); let req = self diff --git a/crates/bcr-ebill-api/src/tests/mod.rs b/crates/bcr-ebill-api/src/tests/mod.rs index 3a9c0567..02a536dc 100644 --- a/crates/bcr-ebill-api/src/tests/mod.rs +++ b/crates/bcr-ebill-api/src/tests/mod.rs @@ -91,6 +91,7 @@ pub mod tests { mint_request_id: &str, new_status: &MintRequestStatus, ) -> Result<()>; + async fn add_proofs_to_offer(&self, mint_request_id: &str, proofs: &str) -> Result<()>; async fn add_offer( &self, mint_request_id: &str, diff --git a/crates/bcr-ebill-persistence/src/constants.rs b/crates/bcr-ebill-persistence/src/constants.rs index 35ce1426..98c0c749 100644 --- a/crates/bcr-ebill-persistence/src/constants.rs +++ b/crates/bcr-ebill-persistence/src/constants.rs @@ -24,8 +24,10 @@ pub const DB_BILL_ID: &str = "bill_id"; pub const DB_STATUS: &str = "status"; pub const DB_STATUS_OFFERED: &str = "status_offered"; pub const DB_STATUS_PENDING: &str = "status_pending"; +pub const DB_STATUS_ACCEPTED: &str = "status_accepted"; pub const DB_MINT_NODE_ID: &str = "mint_node_id"; pub const DB_MINT_REQUEST_ID: &str = "mint_request_id"; +pub const DB_PROOFS: &str = "proofs"; pub const DB_MINT_REQUESTER_NODE_ID: &str = "requester_node_id"; pub const DB_SEARCH_TERM: &str = "search_term"; diff --git a/crates/bcr-ebill-persistence/src/db/mint.rs b/crates/bcr-ebill-persistence/src/db/mint.rs index 6b61309a..e1eb761a 100644 --- a/crates/bcr-ebill-persistence/src/db/mint.rs +++ b/crates/bcr-ebill-persistence/src/db/mint.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use crate::{ Error, constants::{ - DB_BILL_ID, DB_MINT_NODE_ID, DB_MINT_REQUEST_ID, DB_MINT_REQUESTER_NODE_ID, DB_STATUS, - DB_STATUS_OFFERED, DB_STATUS_PENDING, DB_TABLE, + DB_BILL_ID, DB_MINT_NODE_ID, DB_MINT_REQUEST_ID, DB_MINT_REQUESTER_NODE_ID, DB_PROOFS, + DB_STATUS, DB_STATUS_ACCEPTED, DB_STATUS_OFFERED, DB_STATUS_PENDING, DB_TABLE, }, mint::MintStoreApi, }; @@ -72,8 +72,9 @@ impl MintStoreApi for SurrealMintStore { bindings.add(DB_TABLE, Self::REQUESTS_TABLE)?; bindings.add(DB_STATUS_OFFERED, MintRequestStatusDb::Offered)?; bindings.add(DB_STATUS_PENDING, MintRequestStatusDb::Pending)?; + bindings.add(DB_STATUS_ACCEPTED, MintRequestStatusDb::Accepted)?; let results: Vec = self.db - .query("SELECT * from type::table($table) WHERE status = $status_offered OR status = $status_pending", bindings).await?; + .query("SELECT * from type::table($table) WHERE status = $status_offered OR status = $status_pending OR status = $status_accepted", bindings).await?; Ok(results.into_iter().map(|c| c.into()).collect()) } @@ -157,6 +158,25 @@ impl MintStoreApi for SurrealMintStore { Ok(()) } + async fn add_proofs_to_offer(&self, mint_request_id: &str, proofs: &str) -> Result<()> { + // we only add proofs, if there is an offer and it has no proofs yet + if let Ok(Some(offer)) = self.get_offer(mint_request_id).await { + if offer.proofs.is_some() { + return Err(Error::MintOfferAlreadyExists); + } + } else { + return Err(Error::MintOfferDoesNotExist); + } + let mut bindings = Bindings::default(); + bindings.add(DB_TABLE, Self::OFFERS_TABLE)?; + bindings.add(DB_MINT_REQUEST_ID, mint_request_id.to_owned())?; + bindings.add(DB_PROOFS, Some(proofs.to_owned()))?; + self.db + .query_check("UPDATE type::table($table) SET proofs = $proofs WHERE mint_request_id = $mint_request_id", bindings) + .await?; + Ok(()) + } + async fn add_offer( &self, mint_request_id: &str, diff --git a/crates/bcr-ebill-persistence/src/lib.rs b/crates/bcr-ebill-persistence/src/lib.rs index cbf63112..470b485d 100644 --- a/crates/bcr-ebill-persistence/src/lib.rs +++ b/crates/bcr-ebill-persistence/src/lib.rs @@ -52,6 +52,12 @@ pub enum Error { #[error("There is already an offer for the given request to mint")] MintOfferAlreadyExists, + #[error("The offer for the given request to mint already has proofs set")] + MintOfferAlreadyHasProofs, + + #[error("There is no offer for the given request to mint")] + MintOfferDoesNotExist, + #[error("Identity Block could not be added: {0}")] AddIdentityBlock(String), diff --git a/crates/bcr-ebill-persistence/src/mint.rs b/crates/bcr-ebill-persistence/src/mint.rs index 19c25f5e..c64b9285 100644 --- a/crates/bcr-ebill-persistence/src/mint.rs +++ b/crates/bcr-ebill-persistence/src/mint.rs @@ -18,7 +18,7 @@ pub trait MintStoreApi: ServiceTraitBounds { bill_id: &str, mint_node_id: &str, ) -> Result>; - /// Returns all mint requests, which are not finished (i.e. offered, or pending) + /// Returns all mint requests, which are not finished (i.e. offered, accepted or pending) async fn get_all_active_requests(&self) -> Result>; /// Checks if there is an active request to mint for the given bill async fn get_requests_for_bill( @@ -43,6 +43,8 @@ pub trait MintStoreApi: ServiceTraitBounds { mint_request_id: &str, new_status: &MintRequestStatus, ) -> Result<()>; + /// Adds proofs for a given offer + async fn add_proofs_to_offer(&self, mint_request_id: &str, proofs: &str) -> Result<()>; /// Adds an offer for a request to mint async fn add_offer( &self,