//! HTTP client for Cashu mint API (NUT-01 through NUT-06). //! //! Communicates with a Cashu-compatible mint for: //! - Keyset discovery (GET /v1/keys, /v1/keysets) //! - Mint quotes and minting (POST /v1/mint/quote/bolt11, /v1/mint/bolt11) //! - Melt quotes and melting (POST /v1/melt/quote/bolt11, /v1/melt/bolt11) //! - Token swaps (POST /v1/swap) //! - Proof state checks (POST /v1/checkstate) use super::bdhke; use super::cashu::{ amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tracing::debug; /// Default timeout for mint API calls. const MINT_TIMEOUT_SECS: u64 = 10; /// Timeout for heavy operations (minting with Lightning payment). const MINT_HEAVY_TIMEOUT_SECS: u64 = 30; /// Mint quote response (NUT-04). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintQuote { pub quote: String, pub request: String, // BOLT11 Lightning invoice pub state: String, // "UNPAID", "PAID", "ISSUED" #[serde(default)] pub expiry: u64, } /// Melt quote response (NUT-05). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeltQuote { pub quote: String, pub amount: u64, pub fee_reserve: u64, pub state: String, // "UNPAID", "PENDING", "PAID" #[serde(default)] pub expiry: u64, } /// Token state from checkstate (NUT-07). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProofState { #[serde(rename = "Y")] pub y: String, pub state: String, // "UNSPENT", "SPENT", "PENDING" } /// Result of a swap operation. pub struct SwapResult { pub new_proofs: Vec, } /// Result of a mint operation. pub struct MintResult { pub proofs: Vec, } /// HTTP client for a single Cashu mint. pub struct MintClient { url: String, client: reqwest::Client, } impl MintClient { /// Create a new mint client for the given mint URL. pub fn new(mint_url: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS)) .build() .context("Failed to build HTTP client for mint")?; Ok(Self { url: mint_url.trim_end_matches('/').to_string(), client, }) } /// Create a mint client with a custom reqwest client (e.g., for Tor proxy). pub fn with_client(mint_url: &str, client: reqwest::Client) -> Self { Self { url: mint_url.trim_end_matches('/').to_string(), client, } } pub fn url(&self) -> &str { &self.url } // ── Keyset discovery (NUT-01, NUT-02) ── /// Fetch the active keyset from the mint. pub async fn get_keys(&self) -> Result> { let url = format!("{}/v1/keys", self.url); let res = self .client .get(&url) .send() .await .context("Failed to fetch mint keys")?; if !res.status().is_success() { anyhow::bail!("Mint keys request failed: {}", res.status()); } let body: serde_json::Value = res.json().await.context("Failed to parse mint keys")?; let keysets: Vec = serde_json::from_value( body.get("keysets") .cloned() .unwrap_or(serde_json::json!([])), ) .context("Failed to parse keysets")?; Ok(keysets) } /// Get the active keyset for the "sat" unit. pub async fn get_active_sat_keyset(&self) -> Result { let keysets = self.get_keys().await?; keysets .into_iter() .find(|k| { // Find active sat keyset — check keys map is non-empty !k.keys.is_empty() }) .ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url)) } // ── Mint quotes (NUT-04) ── /// Request a mint quote — returns a Lightning invoice to pay. pub async fn mint_quote(&self, amount: u64) -> Result { let url = format!("{}/v1/mint/quote/bolt11", self.url); let res = self .client .post(&url) .json(&serde_json::json!({ "amount": amount, "unit": "sat" })) .send() .await .context("Failed to request mint quote")?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("Mint quote failed ({}): {}", status, body); } res.json().await.context("Failed to parse mint quote") } /// Check the status of a mint quote. pub async fn mint_quote_status(&self, quote_id: &str) -> Result { let url = format!("{}/v1/mint/quote/bolt11/{}", self.url, quote_id); let res = self .client .get(&url) .send() .await .context("Failed to check mint quote status")?; if !res.status().is_success() { anyhow::bail!("Mint quote status check failed: {}", res.status()); } res.json() .await .context("Failed to parse mint quote status") } /// Mint tokens after Lightning invoice has been paid. /// Performs BDHKE blinding, sends blinded messages to mint, unblinds signatures. pub async fn mint_tokens(&self, quote_id: &str, amount: u64) -> Result { let keyset = self.get_active_sat_keyset().await?; let denominations = amount_to_denominations(amount); let mut blinded_messages = Vec::new(); let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount) for &denom in &denominations { let secret = bdhke::generate_secret(); let r = bdhke::random_blinding_factor(); let blinded = bdhke::blind_message(&secret, &r)?; blinded_messages.push(BlindedMessageRequest { amount: denom, id: keyset.id.clone(), b_prime: hex::encode(blinded.b_prime.serialize()), }); blinding_data.push((secret, r, denom)); } let url = format!("{}/v1/mint/bolt11", self.url); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS)) .build() .context("Failed to build client for mint operation")?; let res = client .post(&url) .json(&serde_json::json!({ "quote": quote_id, "outputs": blinded_messages, })) .send() .await .context("Failed to mint tokens")?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("Mint tokens failed ({}): {}", status, body); } let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?; let signatures: Vec = serde_json::from_value( body.get("signatures") .cloned() .unwrap_or(serde_json::json!([])), ) .context("Failed to parse blind signatures")?; if signatures.len() != blinding_data.len() { anyhow::bail!( "Mint returned {} signatures, expected {}", signatures.len(), blinding_data.len() ); } // Unblind signatures to get real proofs let mut proofs = Vec::new(); for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { let c_prime = sig.c_prime_as_pubkey()?; let mint_key = keyset.key_for_amount(*amount)?; let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?; proofs.push(Proof { amount: *amount, id: keyset.id.clone(), secret: String::from_utf8_lossy(secret).to_string(), c: hex::encode(c.serialize()), }); } debug!("Minted {} proofs totaling {} sats", proofs.len(), amount); Ok(MintResult { proofs }) } // ── Melt (NUT-05) ── /// Request a melt quote — how much it costs to pay a Lightning invoice. pub async fn melt_quote(&self, bolt11: &str) -> Result { let url = format!("{}/v1/melt/quote/bolt11", self.url); let res = self .client .post(&url) .json(&serde_json::json!({ "request": bolt11, "unit": "sat" })) .send() .await .context("Failed to request melt quote")?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("Melt quote failed ({}): {}", status, body); } res.json().await.context("Failed to parse melt quote") } /// Melt tokens — pay a Lightning invoice using ecash proofs. pub async fn melt_tokens(&self, quote_id: &str, proofs: &[Proof]) -> Result { let url = format!("{}/v1/melt/bolt11", self.url); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS)) .build() .context("Failed to build client for melt operation")?; let res = client .post(&url) .json(&serde_json::json!({ "quote": quote_id, "inputs": proofs, })) .send() .await .context("Failed to melt tokens")?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("Melt failed ({}): {}", status, body); } res.json().await.context("Failed to parse melt response") } // ── Swap (NUT-03) ── /// Swap proofs for new proofs of different denominations. /// This is how we "receive" a token — swap it for fresh proofs that only we know. pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result { let keyset = self.get_active_sat_keyset().await?; let mut blinded_messages = Vec::new(); let mut blinding_data = Vec::new(); for &amount in target_amounts { let secret = bdhke::generate_secret(); let r = bdhke::random_blinding_factor(); let blinded = bdhke::blind_message(&secret, &r)?; blinded_messages.push(BlindedMessageRequest { amount, id: keyset.id.clone(), b_prime: hex::encode(blinded.b_prime.serialize()), }); blinding_data.push((secret, r, amount)); } let url = format!("{}/v1/swap", self.url); let res = self .client .post(&url) .json(&serde_json::json!({ "inputs": inputs, "outputs": blinded_messages, })) .send() .await .context("Failed to swap tokens")?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("Swap failed ({}): {}", status, body); } let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?; let signatures: Vec = serde_json::from_value( body.get("signatures") .cloned() .unwrap_or(serde_json::json!([])), ) .context("Failed to parse swap signatures")?; if signatures.len() != blinding_data.len() { anyhow::bail!( "Swap returned {} signatures, expected {}", signatures.len(), blinding_data.len() ); } let mut new_proofs = Vec::new(); for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { let c_prime = sig.c_prime_as_pubkey()?; let mint_key = keyset.key_for_amount(*amount)?; let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?; new_proofs.push(Proof { amount: *amount, id: keyset.id.clone(), secret: String::from_utf8_lossy(secret).to_string(), c: hex::encode(c.serialize()), }); } debug!( "Swapped {} inputs for {} new proofs", inputs.len(), new_proofs.len() ); Ok(SwapResult { new_proofs }) } // ── Check state (NUT-07) ── /// Check whether proofs are spent, unspent, or pending. pub async fn check_state(&self, proofs: &[Proof]) -> Result> { // Compute Y = hash_to_curve(secret) for each proof let ys: Vec = proofs .iter() .map(|p| { let y = bdhke::hash_to_curve(p.secret.as_bytes())?; Ok(hex::encode(y.serialize())) }) .collect::>>()?; let url = format!("{}/v1/checkstate", self.url); let res = self .client .post(&url) .json(&serde_json::json!({ "Ys": ys })) .send() .await .context("Failed to check proof state")?; if !res.status().is_success() { anyhow::bail!("Check state failed: {}", res.status()); } let body: serde_json::Value = res .json() .await .context("Failed to parse checkstate response")?; let states: Vec = serde_json::from_value(body.get("states").cloned().unwrap_or(serde_json::json!([]))) .context("Failed to parse proof states")?; Ok(states) } /// Receive a CashuToken by swapping its proofs for fresh ones. /// This prevents double-spend and ensures only we can spend the new proofs. pub async fn receive_token(&self, token: &CashuToken) -> Result> { let mut all_new_proofs = Vec::new(); for entry in &token.token { if entry.mint != self.url { debug!( "Skipping proofs from different mint {} (ours: {})", entry.mint, self.url ); continue; } let total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let target_amounts = amount_to_denominations(total); let result = self.swap(&entry.proofs, &target_amounts).await?; all_new_proofs.extend(result.new_proofs); } if all_new_proofs.is_empty() { anyhow::bail!("No proofs could be swapped — mint mismatch or empty token"); } Ok(all_new_proofs) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_mint_client_url_normalization() { let client = MintClient::new("http://mint.example.com/").unwrap(); assert_eq!(client.url(), "http://mint.example.com"); } #[test] fn test_mint_client_url_no_trailing_slash() { let client = MintClient::new("http://mint.example.com").unwrap(); assert_eq!(client.url(), "http://mint.example.com"); } }