From ed43c5fbfbf55deb986e859b27f8a22dbab1e11b Mon Sep 17 00:00:00 2001 From: "kody.low" Date: Sat, 25 May 2024 11:02:11 -0700 Subject: [PATCH] feat: stateless client --- Cargo.lock | 35 ++++ Cargo.toml | 2 +- clientd-stateless/Cargo.toml | 45 +++++ clientd-stateless/src/cashu.rs | 182 ++++++++++++++++++++ clientd-stateless/src/error.rs | 49 ++++++ clientd-stateless/src/main.rs | 178 +++++++++++++++++++ clientd-stateless/src/router/check.rs | 34 ++++ clientd-stateless/src/router/info.rs | 111 ++++++++++++ clientd-stateless/src/router/keys.rs | 17 ++ clientd-stateless/src/router/keysets.rs | 26 +++ clientd-stateless/src/router/melt/method.rs | 182 ++++++++++++++++++++ clientd-stateless/src/router/melt/mod.rs | 2 + clientd-stateless/src/router/melt/quote.rs | 16 ++ clientd-stateless/src/router/mint/method.rs | 128 ++++++++++++++ clientd-stateless/src/router/mint/mod.rs | 2 + clientd-stateless/src/router/mint/quote.rs | 16 ++ clientd-stateless/src/router/mod.rs | 7 + clientd-stateless/src/router/swap.rs | 52 ++++++ clientd-stateless/src/state.rs | 50 ++++++ clientd-stateless/src/utils.rs | 11 ++ 20 files changed, 1144 insertions(+), 1 deletion(-) create mode 100644 clientd-stateless/Cargo.toml create mode 100644 clientd-stateless/src/cashu.rs create mode 100644 clientd-stateless/src/error.rs create mode 100644 clientd-stateless/src/main.rs create mode 100644 clientd-stateless/src/router/check.rs create mode 100644 clientd-stateless/src/router/info.rs create mode 100644 clientd-stateless/src/router/keys.rs create mode 100644 clientd-stateless/src/router/keysets.rs create mode 100644 clientd-stateless/src/router/melt/method.rs create mode 100644 clientd-stateless/src/router/melt/mod.rs create mode 100644 clientd-stateless/src/router/melt/quote.rs create mode 100644 clientd-stateless/src/router/mint/method.rs create mode 100644 clientd-stateless/src/router/mint/mod.rs create mode 100644 clientd-stateless/src/router/mint/quote.rs create mode 100644 clientd-stateless/src/router/mod.rs create mode 100644 clientd-stateless/src/router/swap.rs create mode 100644 clientd-stateless/src/state.rs create mode 100644 clientd-stateless/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index e74afd6..149a43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,6 +709,41 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "clientd-stateless" +version = "0.3.5" +dependencies = [ + "anyhow", + "async-utility", + "axum", + "axum-macros", + "axum-otel-metrics", + "base64 0.22.0", + "bitcoin 0.29.2", + "bitcoin_hashes 0.13.0", + "chrono", + "clap", + "dotenv", + "fedimint", + "fedimint-tbs", + "futures-util", + "hex", + "itertools 0.12.1", + "lazy_static", + "lightning-invoice", + "lnurl-rs", + "multimint 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", + "reqwest 0.12.4", + "serde", + "serde_json", + "time", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "concurrent-queue" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index d29ed0c..1d6e1b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["multimint", "fedimint-clientd"] +members = ["multimint", "fedimint-clientd", "clientd-stateless"] resolver = "2" [workspace.package] diff --git a/clientd-stateless/Cargo.toml b/clientd-stateless/Cargo.toml new file mode 100644 index 0000000..ecc8ab7 --- /dev/null +++ b/clientd-stateless/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "clientd-stateless" +description = "A stateless fedimint client daemon" +version.workspace = true +edition.workspace = true +repository.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +authors.workspace = true + +[dependencies] +anyhow = "1.0.75" +axum = { version = "0.7.1", features = ["json", "ws"] } +axum-macros = "0.4.0" +dotenv = "0.15.0" +fedimint = "0.0.1" +serde = "1.0.193" +serde_json = "1.0.108" +tokio = { version = "1.34.0", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = "0.3.18" +url = "2.5.0" +lazy_static = "1.4.0" +async-utility = "0.2.0" +tower-http = { version = "0.5.2", features = ["cors", "auth", "trace"] } +bitcoin = "0.29.2" +itertools = "0.12.0" +lnurl-rs = { version = "0.5.0", features = ["async"], default-features = false } +reqwest = { version = "0.12.3", features = [ + "json", + "rustls-tls", +], default-features = false } +lightning-invoice = { version = "0.26.0", features = ["serde"] } +bitcoin_hashes = "0.13.0" +time = { version = "0.3.25", features = ["formatting"] } +chrono = "0.4.31" +futures-util = "0.3.30" +clap = { version = "3", features = ["derive", "env"] } +multimint = { version = "0.3.6" } +# 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/src/cashu.rs b/clientd-stateless/src/cashu.rs new file mode 100644 index 0000000..bba10b0 --- /dev/null +++ b/clientd-stateless/src/cashu.rs @@ -0,0 +1,182 @@ +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 multimint::fedimint_core::api::InviteCode; +use multimint::fedimint_core::config::{FederationId, FederationIdPrefix}; +use multimint::fedimint_core::db::DatabaseValue; +use multimint::fedimint_core::module::registry::ModuleDecoderRegistry; +use multimint::fedimint_core::{Amount, TieredMulti}; +use multimint::fedimint_mint_client::{OOBNotes, SpendableNote}; +use serde::de::Error; +use serde::{Deserialize, Serialize}; +use tbs::Signature; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[allow(non_snake_case)] +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 + 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, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Unit { + Msat, + Sat, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Method { + Bolt11, + Onchain, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub struct Keyset { + id: String, + unit: Unit, + active: bool, +} + +impl From for Keyset { + fn from(federation_id: FederationId) -> Self { + let as_str = format!("00{}", federation_id.to_string()); + Keyset { + id: as_str, + unit: Unit::Msat, + active: true, + } + } +} diff --git a/clientd-stateless/src/error.rs b/clientd-stateless/src/error.rs new file mode 100644 index 0000000..7ecc428 --- /dev/null +++ b/clientd-stateless/src/error.rs @@ -0,0 +1,49 @@ +use std::fmt; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +pub struct AppError { + pub error: anyhow::Error, + pub status: StatusCode, +} + +impl AppError { + pub fn new(status: StatusCode, error: impl Into) -> Self { + Self { + error: error.into(), + status, + } + } +} + +// Tell axum how to convert `AppError` into a response. +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, format!("Something went wrong: {}", self.error)).into_response() + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let error_json = json!({ + "error": self.error.to_string(), + "status": self.status.as_u16(), + }); + + write!(f, "{}", error_json) + } +} + +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self { + error: err.into(), + status: StatusCode::INTERNAL_SERVER_ERROR, // default status code + } + } +} diff --git a/clientd-stateless/src/main.rs b/clientd-stateless/src/main.rs new file mode 100644 index 0000000..a9fcc06 --- /dev/null +++ b/clientd-stateless/src/main.rs @@ -0,0 +1,178 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::Result; +use axum::http::Method; +use multimint::fedimint_core::api::InviteCode; +use router::{check, info, keys, keysets, melt, mint, swap}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing::info; + +mod cashu; +mod error; +pub mod router; +mod state; +mod utils; + +use axum::routing::{get, post}; +use axum::Router; +use axum_otel_metrics::HttpMetricsLayerBuilder; +use clap::{Parser, Subcommand}; +use state::AppState; + +#[derive(Subcommand)] +enum Commands { + Start, + Stop, +} + +#[derive(Parser)] +#[clap(version = "1.0", author = "Kody Low")] +struct Cli { + /// Federation invite code + #[clap(long, env = "FEDIMINT_CLIENTD_INVITE_CODE", required = false)] + invite_code: String, + + /// Path to FM database + #[clap(long, env = "FEDIMINT_CLIENTD_DB_PATH", required = true)] + db_path: PathBuf, + + /// Password + #[clap(long, env = "FEDIMINT_CLIENTD_PASSWORD", required = true)] + password: String, + + /// Addr + #[clap(long, env = "FEDIMINT_CLIENTD_ADDR", required = true)] + addr: String, + + /// Manual secret + #[clap(long, env = "FEDIMINT_CLIENTD_MANUAL_SECRET", required = false)] + manual_secret: Option, +} + +// const PID_FILE: &str = "/tmp/fedimint_http.pid"; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + dotenv::dotenv().ok(); + + let cli: Cli = Cli::parse(); + + let mut state = AppState::new(cli.db_path).await?; + + let manual_secret = match cli.manual_secret { + Some(secret) => Some(secret), + None => match std::env::var("FEDIMINT_CLIENTD_MANUAL_SECRET") { + Ok(secret) => Some(secret), + Err(_) => None, + }, + }; + + match InviteCode::from_str(&cli.invite_code) { + Ok(invite_code) => { + let federation_id = state + .multimint + .register_new(invite_code, manual_secret) + .await?; + info!("Created client for federation id: {:?}", federation_id); + } + Err(e) => { + info!( + "No federation invite code provided, skipping client creation: {}", + e + ); + } + } + + if state.multimint.all().await.is_empty() { + return Err(anyhow::anyhow!("No clients found, must have at least one client to start the server. Try providing a federation invite code with the `--invite-code` flag or setting the `FEDIMINT_CLIENTD_INVITE_CODE` environment variable.")); + } + + let app = Router::new().nest("/v1", cashu_v1_rest()).with_state(state); + info!("Starting stateless server"); + + let cors = CorsLayer::new() + // allow `GET` and `POST` when accessing the resource + .allow_methods([Method::GET, Method::POST]) + // allow requests from any origin + .allow_origin(Any) + // allow auth header + .allow_headers(Any); + + let metrics = HttpMetricsLayerBuilder::new() + .with_service_name("fedimint-clientd".to_string()) + .build(); + + let app = app + .layer(cors) + .layer(TraceLayer::new_for_http()) // tracing requests + // no traces for routes bellow + .route("/health", get(|| async { "Server is up and running!" })) // for health check + // metrics for all routes above + .merge(metrics.routes()) + .layer(metrics); + + let listener = tokio::net::TcpListener::bind(format!("{}", &cli.addr)) + .await + .map_err(|e| anyhow::anyhow!("Failed to bind to address, should be a valid address and port like 127.0.0.1:3333: {e}"))?; + info!("fedimint-clientd Listening on {}", &cli.addr); + axum::serve(listener, app) + .await + .map_err(|e| anyhow::anyhow!("Failed to start server: {e}"))?; + + Ok(()) +} + +/// Implements Cashu V1 API Routes: +/// +/// REQUIRED +/// NUT-01 Mint Public Key Exchange && NUT-02 Keysets and Keyset IDs +/// - `/cashu/v1/keys` +/// - `/cashu/v1/keys/{keyset_id}` +/// - `/cashu/v1/keysets` +/// NUT-03 Swap Tokens (Equivalent to `reissue` command) +/// - `/cashu/v1/swap` +/// NUT-04 Mint Tokens: supports `bolt11` and `onchain` methods +/// - `/cashu/v1/mint/quote/{method}` +/// - `/cashu/v1/mint/quote/{method}/{quote_id}` +/// - `/cashu/v1/mint/{method}` +/// NUT-05 Melting Tokens: supports `bolt11` and `onchain` methods +/// - `/cashu/v1/melt/quote/{method}` +/// - `/cashu/v1/melt/quote/{method}/{quote_id}` +/// - `/cashu/v1/melt/{method}` +/// NUT-06 Mint Information +/// - `/cashu/v1/info` +/// +/// OPTIONAL +/// NUT-07 Token State Check +/// - `/cashu/v1/check` +/// NUT-08 Lightning Fee Return +/// - Modification of NUT-05 Melt +/// NUT-10 Spending Conditions +/// NUT-11 Pay to Public Key (P2PK) +/// - Fedimint already does this +/// NUT-12 Offline Ecash Signature Validation +/// - DLEQ in BlindedSignature for Mint to User +fn cashu_v1_rest() -> Router { + Router::new() + .route("/keys", get(keys::handle_keys)) + .route("/keys/:keyset_id", get(keys::handle_keys_keyset_id)) + .route("/keysets", get(keysets::handle_keysets)) + .route("/swap", post(swap::handle_swap)) + .route("/mint/quote/:method", get(mint::quote::handle_method)) + .route( + "/mint/quote/:method/:quote_id", + get(mint::quote::handle_method_quote_id), + ) + .route("/mint/:method", post(mint::method::handle_method)) + .route("/melt/quote/:method", get(melt::quote::handle_method)) + .route( + "/melt/quote/:method/:quote_id", + get(melt::quote::handle_method_quote_id), + ) + .route("/melt/:method", post(melt::method::handle_method)) + .route("/info", get(info::handle_info)) + .route("/check", post(check::handle_check)) +} diff --git a/clientd-stateless/src/router/check.rs b/clientd-stateless/src/router/check.rs new file mode 100644 index 0000000..cedc595 --- /dev/null +++ b/clientd-stateless/src/router/check.rs @@ -0,0 +1,34 @@ +use axum::extract::State; +use axum::Json; +use multimint::fedimint_core::Amount; +use multimint::fedimint_mint_client::{MintClientModule, OOBNotes}; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CheckRequest { + pub notes: OOBNotes, +} + +#[derive(Debug, Serialize)] +pub struct CheckResponse { + pub amount_msat: Amount, +} + +#[axum_macros::debug_handler] +pub async fn handle_check( + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let client = state + .get_client_by_prefix(&req.notes.federation_id_prefix()) + .await?; + let amount_msat = client + .get_first_module::() + .validate_notes(req.notes) + .await?; + + Ok(Json(CheckResponse { amount_msat })) +} diff --git a/clientd-stateless/src/router/info.rs b/clientd-stateless/src/router/info.rs new file mode 100644 index 0000000..a6042e8 --- /dev/null +++ b/clientd-stateless/src/router/info.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; +use std::env; +use std::str::FromStr; + +use axum::extract::State; +use axum::Json; +use multimint::fedimint_core::config::FederationId; +use serde::Serialize; + +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct Contact { + pub method: String, + pub value: String, +} + +#[derive(Debug, Serialize)] +pub struct NutMethod { + pub method: String, + pub value: String, +} + +#[derive(Debug, Serialize)] +pub struct Nut { + pub methods: Option>, + pub disabled: Option, + pub supported: Option, +} + +#[derive(Debug, Serialize)] +pub struct CashuNUT06InfoResponse { + pub name: String, + pub pubkey: String, + pub version: String, + pub description: String, + pub description_long: String, + pub contact: Vec, + pub motd: String, + pub nuts: BTreeMap, +} + +#[axum_macros::debug_handler] +pub async fn handle_info( + State(state): State, +) -> Result, AppError> { + 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(); + + nuts.insert( + "4".to_string(), + Nut { + methods: Some(vec![NutMethod { + method: "bolt11".to_string(), + value: "sat".to_string(), + }]), + disabled: Some(false), + supported: None, + }, + ); + + nuts.insert( + "5".to_string(), + Nut { + methods: Some(vec![NutMethod { + method: "bolt11".to_string(), + value: "sat".to_string(), + }]), + disabled: None, + supported: None, + }, + ); + + for &i in &[7, 8, 9, 10, 12] { + nuts.insert( + i.to_string(), + Nut { + methods: None, + disabled: None, + supported: Some(true), + }, + ); + } + + let response = CashuNUT06InfoResponse { + name: config + .global + .federation_name() + .unwrap_or_default() + .to_string(), + pubkey: config.global.calculate_federation_id().to_string(), + version: format!("{:?}", config.global.consensus_version), + description: "Cashu <-> Fedimint Soon (tm)".to_string(), + description_long: "Cashu <-> Fedimint Soon (tm)".to_string(), + contact: vec![Contact { + method: "xmpp".to_string(), + value: "local@localhost".to_string(), + }], + motd: "Cashu <-> Fedimint Soon (tm)".to_string(), + nuts, + }; + + Ok(Json(response)) +} diff --git a/clientd-stateless/src/router/keys.rs b/clientd-stateless/src/router/keys.rs new file mode 100644 index 0000000..eeb6c78 --- /dev/null +++ b/clientd-stateless/src/router/keys.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use axum::extract::State; + +use crate::error::AppError; +use crate::state::AppState; + +#[axum_macros::debug_handler] +pub async fn handle_keys(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} + +#[axum_macros::debug_handler] +pub async fn handle_keys_keyset_id(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} diff --git a/clientd-stateless/src/router/keysets.rs b/clientd-stateless/src/router/keysets.rs new file mode 100644 index 0000000..a721c40 --- /dev/null +++ b/clientd-stateless/src/router/keysets.rs @@ -0,0 +1,26 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; + +use crate::cashu::Keyset; +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub struct KeysetsResponse { + keysets: Vec, +} + +#[axum_macros::debug_handler] +pub async fn handle_keysets( + State(state): State, +) -> Result, AppError> { + let mut keysets = Vec::::new(); + let ids = state.multimint.ids().await; + for id in ids { + keysets.push(Keyset::from(id)) + } + + Ok(Json(KeysetsResponse { keysets })) +} diff --git a/clientd-stateless/src/router/melt/method.rs b/clientd-stateless/src/router/melt/method.rs new file mode 100644 index 0000000..a048a0c --- /dev/null +++ b/clientd-stateless/src/router/melt/method.rs @@ -0,0 +1,182 @@ +use std::str::FromStr; + +use anyhow::anyhow; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use futures_util::StreamExt; +use lightning_invoice::Bolt11Invoice; +use multimint::fedimint_client::ClientHandleArc; +use multimint::fedimint_core::config::FederationId; +use multimint::fedimint_core::Amount; +use multimint::fedimint_ln_client::{LightningClientModule, OutgoingLightningPayment}; +use multimint::fedimint_wallet_client::{WalletClientModule, WithdrawState}; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +use crate::cashu::{Method, Unit}; +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct PostMeltQuoteMethodRequest { + pub request: String, + pub amount: Amount, + pub unit: Unit, + pub federation_id: FederationId, +} + +#[derive(Debug, Serialize)] +pub struct PostMeltQuoteMethodResponse { + pub quote: String, + pub amount: Amount, + pub fee_reserve: Amount, + pub paid: bool, + pub expiry: u64, +} + +#[axum_macros::debug_handler] +pub async fn handle_method( + Path(method): Path, + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let client = state.get_client(req.federation_id).await?; + let res = match method { + Method::Bolt11 => match req.unit { + Unit::Msat => melt_bolt11(client, req.request, req.amount).await, + Unit::Sat => melt_bolt11(client, req.request, req.amount * 1000).await, + }, + Method::Onchain => match req.unit { + Unit::Msat => { + let amount_sat = bitcoin::Amount::from_sat(req.amount.try_into_sats()?); + melt_onchain(client, req.request, amount_sat).await + } + Unit::Sat => { + let amount_sat = req.amount * 1000; + let amount_sat = bitcoin::Amount::from_sat(amount_sat.try_into_sats()?); + melt_onchain(client, req.request, amount_sat).await + } + }, + }?; + + Ok(Json(res)) +} + +pub async fn melt_bolt11( + client: ClientHandleArc, + request: String, + amount_msat: Amount, +) -> Result { + let lightning_module = client.get_first_module::(); + let gateway_id = match lightning_module.list_gateways().await.first() { + Some(gateway_announcement) => gateway_announcement.info.gateway_id, + None => { + error!("No gateways available"); + return Err(AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("No gateways available"), + )); + } + }; + let gateway = lightning_module + .select_gateway(&gateway_id) + .await + .ok_or_else(|| { + error!("Failed to select gateway"); + AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("Failed to select gateway"), + ) + })?; + + let bolt11 = Bolt11Invoice::from_str(&request)?; + let bolt11_amount = Amount::from_msats( + bolt11 + .amount_milli_satoshis() + .ok_or_else(|| anyhow!("Cannot pay amountless invoices",))?, + ); + + if bolt11_amount != amount_msat { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + anyhow!( + "Invoice amount ({}) does not match request amount ({})", + bolt11_amount, + amount_msat + ), + )); + } + + let OutgoingLightningPayment { + payment_type, + contract_id: _, + fee, + } = lightning_module + .pay_bolt11_invoice(Some(gateway), bolt11, ()) + .await?; + + let operation_id = payment_type.operation_id(); + info!("Gateway fee: {fee}, payment operation id: {operation_id}"); + + Ok(PostMeltQuoteMethodResponse { + quote: operation_id.to_string(), + amount: amount_msat, + fee_reserve: fee, + paid: false, + expiry: 0, + }) +} + +async fn melt_onchain( + client: ClientHandleArc, + request: String, + amount_sat: bitcoin::Amount, +) -> Result { + let address = bitcoin::Address::from_str(&request) + .map_err(|e| anyhow::anyhow!("Onchain request must be a valid bitcoin address: {e}"))?; + let wallet_module = client.get_first_module::(); + let fees = wallet_module + .get_withdraw_fees(address.clone(), amount_sat) + .await?; + let absolute_fees = fees.amount(); + + info!("Attempting withdraw with fees: {fees:?}"); + + let operation_id = wallet_module + .withdraw(address, amount_sat, fees, ()) + .await?; + + let mut updates = wallet_module + .subscribe_withdraw_updates(operation_id) + .await? + .into_stream(); + + while let Some(update) = updates.next().await { + info!("Update: {update:?}"); + + match update { + WithdrawState::Succeeded(_txid) => { + return Ok(PostMeltQuoteMethodResponse { + quote: operation_id.to_string(), + amount: amount_sat.into(), + fee_reserve: absolute_fees.into(), + paid: true, + expiry: 0, + }); + } + WithdrawState::Failed(e) => { + return Err(AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("Onchain melt failed: {:?}", e), + )); + } + _ => continue, + }; + } + + Err(AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("Update stream ended without outcome"), + )) +} diff --git a/clientd-stateless/src/router/melt/mod.rs b/clientd-stateless/src/router/melt/mod.rs new file mode 100644 index 0000000..dd31219 --- /dev/null +++ b/clientd-stateless/src/router/melt/mod.rs @@ -0,0 +1,2 @@ +pub mod method; +pub mod quote; diff --git a/clientd-stateless/src/router/melt/quote.rs b/clientd-stateless/src/router/melt/quote.rs new file mode 100644 index 0000000..d5c2714 --- /dev/null +++ b/clientd-stateless/src/router/melt/quote.rs @@ -0,0 +1,16 @@ +use axum::extract::State; + +use crate::error::AppError; +use crate::state::AppState; + +#[axum_macros::debug_handler] +pub async fn handle_method(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} + +#[axum_macros::debug_handler] +pub async fn handle_method_quote_id(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} diff --git a/clientd-stateless/src/router/mint/method.rs b/clientd-stateless/src/router/mint/method.rs new file mode 100644 index 0000000..c8aa9bb --- /dev/null +++ b/clientd-stateless/src/router/mint/method.rs @@ -0,0 +1,128 @@ +use std::time::Duration; + +use anyhow::anyhow; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; +use multimint::fedimint_client::ClientHandleArc; +use multimint::fedimint_core::config::FederationId; +use multimint::fedimint_core::time::now; +use multimint::fedimint_core::Amount; +use multimint::fedimint_ln_client::LightningClientModule; +use multimint::fedimint_wallet_client::WalletClientModule; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use crate::cashu::{Method, Unit}; +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PostMintQuoteMethodRequest { + pub amount: Amount, + pub unit: Unit, + pub federation_id: FederationId, +} + +#[derive(Debug, Serialize)] +pub struct PostMintQuoteMethodResponse { + pub quote: String, + pub request: String, + pub paid: bool, + pub expiry: u64, +} + +#[axum_macros::debug_handler] +pub async fn handle_method( + Path(method): Path, + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let client = state.get_client(req.federation_id).await?; + let res = match method { + Method::Bolt11 => match req.unit { + Unit::Msat => mint_bolt11(client, req.amount).await, + Unit::Sat => mint_bolt11(client, req.amount * 1000).await, + }, + Method::Onchain => match req.unit { + Unit::Msat => Err(AppError::new( + StatusCode::BAD_REQUEST, + anyhow!("Unsupported unit for onchain mint, use sat instead"), + )), + Unit::Sat => mint_onchain(client, req.amount * 1000).await, + }, + }?; + + Ok(Json(res)) +} + +const DEFAULT_MINT_EXPIRY_OFFSET: u64 = 3600; +const DEFAULT_MINT_DESCRIPTION: &str = "Cashu mint operation"; + +pub async fn mint_bolt11( + client: ClientHandleArc, + amount_msat: Amount, +) -> Result { + let valid_until = now() + Duration::from_secs(DEFAULT_MINT_EXPIRY_OFFSET); + let expiry_time = crate::utils::system_time_to_u64(valid_until)?; + let lightning_module = client.get_first_module::(); + let gateway_id = match lightning_module.list_gateways().await.first() { + Some(gateway_announcement) => gateway_announcement.info.gateway_id, + None => { + error!("No gateways available"); + return Err(AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("No gateways available"), + )); + } + }; + let gateway = lightning_module + .select_gateway(&gateway_id) + .await + .ok_or_else(|| { + error!("Failed to select gateway"); + AppError::new( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow!("Failed to select gateway"), + ) + })?; + + let (operation_id, invoice, _) = lightning_module + .create_bolt11_invoice( + amount_msat, + Bolt11InvoiceDescription::Direct(&Description::new( + DEFAULT_MINT_DESCRIPTION.to_string(), + )?), + Some(expiry_time), + (), + Some(gateway), + ) + .await?; + + Ok(PostMintQuoteMethodResponse { + quote: operation_id.to_string(), + request: invoice.to_string(), + paid: false, + expiry: expiry_time, + }) +} + +async fn mint_onchain( + client: ClientHandleArc, + _amount_sat: Amount, +) -> Result { + let wallet_client = client.get_first_module::(); + let valid_until = now() + Duration::from_secs(DEFAULT_MINT_EXPIRY_OFFSET); + let expiry_time = crate::utils::system_time_to_u64(valid_until)?; + + let (operation_id, address) = wallet_client.get_deposit_address(valid_until, ()).await?; + + Ok(PostMintQuoteMethodResponse { + quote: operation_id.to_string(), + request: address.to_string(), + paid: false, + expiry: expiry_time, + }) +} diff --git a/clientd-stateless/src/router/mint/mod.rs b/clientd-stateless/src/router/mint/mod.rs new file mode 100644 index 0000000..dd31219 --- /dev/null +++ b/clientd-stateless/src/router/mint/mod.rs @@ -0,0 +1,2 @@ +pub mod method; +pub mod quote; diff --git a/clientd-stateless/src/router/mint/quote.rs b/clientd-stateless/src/router/mint/quote.rs new file mode 100644 index 0000000..d5c2714 --- /dev/null +++ b/clientd-stateless/src/router/mint/quote.rs @@ -0,0 +1,16 @@ +use axum::extract::State; + +use crate::error::AppError; +use crate::state::AppState; + +#[axum_macros::debug_handler] +pub async fn handle_method(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} + +#[axum_macros::debug_handler] +pub async fn handle_method_quote_id(State(_state): State) -> Result<(), AppError> { + // TODO: Implement this function + Ok(()) +} diff --git a/clientd-stateless/src/router/mod.rs b/clientd-stateless/src/router/mod.rs new file mode 100644 index 0000000..86d3f25 --- /dev/null +++ b/clientd-stateless/src/router/mod.rs @@ -0,0 +1,7 @@ +pub mod check; +pub mod info; +pub mod keys; +pub mod keysets; +pub mod melt; +pub mod mint; +pub mod swap; diff --git a/clientd-stateless/src/router/swap.rs b/clientd-stateless/src/router/swap.rs new file mode 100644 index 0000000..e99742b --- /dev/null +++ b/clientd-stateless/src/router/swap.rs @@ -0,0 +1,52 @@ +use anyhow::anyhow; +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use futures_util::StreamExt; +use multimint::fedimint_core::config::FederationId; +use multimint::fedimint_core::Amount; +use multimint::fedimint_mint_client::{MintClientModule, OOBNotes, ReissueExternalNotesState}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct SwapRequest { + pub notes: OOBNotes, + pub federation_id: FederationId, +} + +#[derive(Debug, Serialize)] +pub struct SwapResponse { + pub amount_msat: Amount, +} + +#[axum_macros::debug_handler] +pub async fn handle_swap( + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let amount_msat = req.notes.total_amount(); + + let client = state.get_client(req.federation_id).await?; + let mint = client.get_first_module::(); + + let operation_id = mint.reissue_external_notes(req.notes, ()).await?; + let mut updates = mint + .subscribe_reissue_external_notes(operation_id) + .await? + .into_stream(); + + while let Some(update) = updates.next().await { + let update_clone = update.clone(); + if let ReissueExternalNotesState::Failed(e) = update { + Err(AppError::new(StatusCode::INTERNAL_SERVER_ERROR, anyhow!(e)))?; + } + + info!("Update: {update_clone:?}"); + } + + Ok(Json(SwapResponse { amount_msat })) +} diff --git a/clientd-stateless/src/state.rs b/clientd-stateless/src/state.rs new file mode 100644 index 0000000..ffc7487 --- /dev/null +++ b/clientd-stateless/src/state.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use multimint::fedimint_client::ClientHandleArc; +use multimint::fedimint_core::config::{FederationId, FederationIdPrefix}; +use multimint::MultiMint; + +use crate::error::AppError; +#[derive(Debug, Clone)] +pub struct AppState { + pub multimint: MultiMint, +} + +impl AppState { + pub async fn new(fm_db_path: PathBuf) -> Result { + let clients = MultiMint::new(fm_db_path).await?; + clients.update_gateway_caches().await?; + Ok(Self { multimint: clients }) + } + + // Helper function to get a specific client from the state or default + pub async fn get_client( + &self, + federation_id: FederationId, + ) -> Result { + match self.multimint.get(&federation_id).await { + Some(client) => Ok(client), + None => Err(AppError::new( + StatusCode::BAD_REQUEST, + anyhow!("No client found for federation id"), + )), + } + } + + pub async fn get_client_by_prefix( + &self, + federation_id_prefix: &FederationIdPrefix, + ) -> Result { + let client = self.multimint.get_by_prefix(federation_id_prefix).await; + + match client { + Some(client) => Ok(client), + None => Err(AppError::new( + StatusCode::BAD_REQUEST, + anyhow!("No client found for federation id prefix"), + )), + } + } +} diff --git a/clientd-stateless/src/utils.rs b/clientd-stateless/src/utils.rs new file mode 100644 index 0000000..5321a72 --- /dev/null +++ b/clientd-stateless/src/utils.rs @@ -0,0 +1,11 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Result}; + +// Helper function to convert SystemTime to u64 +pub fn system_time_to_u64(time: SystemTime) -> Result { + match time.duration_since(UNIX_EPOCH) { + Ok(duration) => Ok(duration.as_secs()), // Converts to seconds + Err(_) => Err(anyhow!("some error")), + } +}