From 4bf43c06eca9dff3d1c42f6ca34d111dc00def36 Mon Sep 17 00:00:00 2001 From: "kody.low" Date: Sat, 27 Apr 2024 10:55:03 -0700 Subject: [PATCH] feat: start stateless token translation --- Cargo.lock | 3 + clientd-stateless/Cargo.toml | 3 + clientd-stateless/examples/cashu_encoding.rs | 1 + clientd-stateless/src/router/handlers/info.rs | 9 +- clientd-stateless/src/router/handlers/mod.rs | 148 ++++++++++++++++++ clientd-stateless/src/state.rs | 22 +-- 6 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 clientd-stateless/examples/cashu_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index 6030ccc..5e1bc41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,7 @@ dependencies = [ "axum", "axum-macros", "axum-otel-metrics", + "base64 0.22.0", "bitcoin 0.29.2", "bitcoin_hashes 0.13.0", "chrono", @@ -717,8 +718,10 @@ dependencies = [ "fedimint-ln-client", "fedimint-mint-client", "fedimint-rocksdb", + "fedimint-tbs", "fedimint-wallet-client", "futures-util", + "hex", "itertools 0.12.1", "lazy_static", "lightning-invoice", diff --git a/clientd-stateless/Cargo.toml b/clientd-stateless/Cargo.toml index c858268..16fb465 100644 --- a/clientd-stateless/Cargo.toml +++ b/clientd-stateless/Cargo.toml @@ -44,3 +44,6 @@ clap = { version = "3", features = ["derive", "env"] } multimint = { version = "0.3.2" } # multimint = { path = "../multimint" } axum-otel-metrics = "0.8.0" +base64 = "0.22.0" +hex = "0.4.3" +fedimint-tbs = "0.3.0" diff --git a/clientd-stateless/examples/cashu_encoding.rs b/clientd-stateless/examples/cashu_encoding.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/clientd-stateless/examples/cashu_encoding.rs @@ -0,0 +1 @@ + diff --git a/clientd-stateless/src/router/handlers/info.rs b/clientd-stateless/src/router/handlers/info.rs index 01452f0..d43ca91 100644 --- a/clientd-stateless/src/router/handlers/info.rs +++ b/clientd-stateless/src/router/handlers/info.rs @@ -1,7 +1,10 @@ use std::collections::BTreeMap; +use std::env; +use std::str::FromStr; use axum::extract::State; use axum::Json; +use fedimint_core::config::FederationId; use serde::Serialize; use crate::error::AppError; @@ -42,7 +45,11 @@ pub struct CashuNUT06InfoResponse { pub async fn handle_info( State(state): State, ) -> Result, AppError> { - let client = state.get_cashu_client().await?; + let federation_id = env::var("FEDIMINT_CLIENTD_ACTIVE_FEDERATION_ID") + .map_err(|e| anyhow::anyhow!("Failed to get active federation id: {}", e))?; + let client = state + .get_client(FederationId::from_str(&federation_id)?) + .await?; let config = client.get_config(); let mut nuts = BTreeMap::new(); diff --git a/clientd-stateless/src/router/handlers/mod.rs b/clientd-stateless/src/router/handlers/mod.rs index daff08c..f4f3512 100644 --- a/clientd-stateless/src/router/handlers/mod.rs +++ b/clientd-stateless/src/router/handlers/mod.rs @@ -1,4 +1,20 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use anyhow::anyhow; +use base64::Engine; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use bitcoin::KeyPair; +use fedimint_core::api::InviteCode; +use fedimint_core::config::FederationIdPrefix; +use fedimint_core::db::DatabaseValue; +use fedimint_core::module::registry::ModuleDecoderRegistry; +use fedimint_core::{Amount, TieredMulti}; +use fedimint_mint_client::{OOBNotes, SpendableNote}; +use serde::de::Error; use serde::{Deserialize, Serialize}; +use tbs::Signature; pub mod check; pub mod info; @@ -8,6 +24,138 @@ pub mod melt; pub mod mint; pub mod swap; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Proof { + // Amount unassociated with the unit + amount: u64, + // keyset id -> FederationId + id: String, + // secret -> hex encoded spend key's secret key + secret: String, + // signature -> hex encoded BLS signature + #[allow(non_snake_case)] + C: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Token { + mint: String, + proofs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TokenV3 { + pub token: Vec, + pub unit: Option, + pub memo: Option, +} + +impl TokenV3 { + /// Serializes the `Token` struct to a base64 URL-safe string without + /// padding and with the version prefix. + pub fn serialize(&self) -> Result { + let json = serde_json::to_string(self) + .map_err(|e| serde_json::Error::custom(format!("Failed to serialize token: {}", e)))?; + let base64_token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes()); + Ok(format!("cashuA{}", base64_token)) + } + + /// Deserializes a base64 URL-safe string without padding (with version + /// prefix) back to a `Token` struct. + pub fn deserialize(encoded: &str) -> Result { + if !encoded.starts_with("cashuA") { + return Err(serde_json::Error::custom("Invalid token format")); + } + let base64_token = &encoded[6..]; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(base64_token.as_bytes()) + .map_err(|e| { + serde_json::Error::custom(format!("Failed to decode base64 token: {}", e)) + })?; + let json = String::from_utf8(bytes).map_err(|e| { + serde_json::Error::custom(format!("Failed to decode base64 token: {}", e)) + })?; + serde_json::from_str(&json) + } + + pub fn from_oobnotes(notes: OOBNotes, invite_code: InviteCode) -> Result { + let mut token = TokenV3 { + token: vec![], + // Always msats + unit: Some("msat".to_string()), + // Federation Invite Code + memo: Some(invite_code.to_string()), + }; + for (amount, note) in notes.notes().iter() { + let mut proofs = vec![]; + for spendable_note in note.iter() { + let proof = Proof { + amount: amount.msats, + // stick the federation id prefix here instead of keyset + id: notes.federation_id_prefix().to_string(), + secret: hex::encode(spendable_note.spend_key.secret_key().to_bytes()), + C: hex::encode(spendable_note.signature.to_bytes()), + }; + proofs.push(proof); + } + token.token.push(Token { + mint: notes.federation_id_prefix().to_string(), + proofs, + }); + } + Ok(token) + } + + fn to_oobnotes(&self, modules: &ModuleDecoderRegistry) -> Result { + let federation_id_prefix = match self.token.first().map(|t| &t.proofs[0].id) { + Some(id) => FederationIdPrefix::from_str(id)?, + None => return Err(anyhow!("No token found")), + }; + let secp = Secp256k1::new(); + let mut notes_map = BTreeMap::>::new(); + for t in self.token.iter() { + for proof in t.proofs.iter() { + let signature_bytes = hex::decode(&proof.C) + .map_err(|e| anyhow!("Failed to decode spendable note signature: {}", e))?; + let signature = Signature::from_bytes(&signature_bytes, modules)?; + let secret_key_bytes = hex::decode(&proof.secret) + .map_err(|e| anyhow!("Failed to decode spendable note spend key: {}", e))?; + let sk = SecretKey::from_bytes(&secret_key_bytes, modules) + .map_err(|e| anyhow!("Failed to decode spendable note spend key: {}", e))?; + let spend_key = KeyPair::from_secret_key(&secp, &sk); + let spendable_note = SpendableNote { + signature, + spend_key, + }; + let amount = Amount::from_msats(proof.amount); + notes_map.entry(amount).or_default().push(spendable_note); + } + } + let tiered_notes = TieredMulti::new(notes_map); + Ok(OOBNotes::new(federation_id_prefix, tiered_notes)) + } +} + +impl FromStr for TokenV3 { + type Err = serde_json::Error; + + /// Parses a string to create a `Token` struct. + /// Assumes the string is a base64 URL-safe encoded JSON of the `Token` with + /// `cashuA` prefix. + fn from_str(s: &str) -> Result { + TokenV3::deserialize(s) + } +} + +impl fmt::Display for TokenV3 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self.serialize() { + Ok(serialized) => write!(f, "{}", serialized), + Err(_) => Err(fmt::Error), + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Unit { diff --git a/clientd-stateless/src/state.rs b/clientd-stateless/src/state.rs index b9f91bb..7db3af5 100644 --- a/clientd-stateless/src/state.rs +++ b/clientd-stateless/src/state.rs @@ -10,17 +10,13 @@ use crate::error::AppError; #[derive(Debug, Clone)] pub struct AppState { pub multimint: MultiMint, - pub cashu_mint: Option, } impl AppState { pub async fn new(fm_db_path: PathBuf) -> Result { let clients = MultiMint::new(fm_db_path).await?; clients.update_gateway_caches(true).await?; - Ok(Self { - multimint: clients, - cashu_mint: None, - }) + Ok(Self { multimint: clients }) } // Helper function to get a specific client from the state or default @@ -37,22 +33,6 @@ impl AppState { } } - pub async fn get_cashu_client(&self) -> Result { - match self.cashu_mint { - Some(client) => match self.multimint.get(&client).await { - Some(client) => Ok(client), - None => Err(AppError::new( - StatusCode::BAD_REQUEST, - anyhow!("No cashu client found for federation id"), - )), - }, - None => Err(AppError::new( - StatusCode::BAD_REQUEST, - anyhow!("No cashu client set"), - )), - } - } - pub async fn get_client_by_prefix( &self, federation_id_prefix: &FederationIdPrefix,