Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: stateless-client #34

Merged
merged 4 commits into from
Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["multimint", "fedimint-clientd"]
members = ["multimint", "fedimint-clientd", "clientd-stateless"]
resolver = "2"
version = "0.3.3"

Expand All @@ -15,7 +15,13 @@ ci = ["github"]
# The installers to generate for each app
installers = ["shell"]
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"x86_64-pc-windows-msvc",
]
# Publish jobs to run in CI
pr-run-mode = "plan"
# Whether to install an updater program
Expand Down
49 changes: 49 additions & 0 deletions clientd-stateless/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[package]
name = "clientd-statless"
version = "0.3.3"
edition = "2021"
description = "A stateless fedimint client daemon"
repository = "https://github.com/fedimint/fedimint-clientd"
keywords = ["fedimint", "bitcoin", "lightning", "ecash"]
license = "MIT"

[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"
fedimint-client = "0.3.0"
fedimint-core = "0.3.0"
fedimint-wallet-client = "0.3.0"
fedimint-mint-client = "0.3.0"
fedimint-ln-client = "0.3.0"
fedimint-rocksdb = "0.3.0"
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.2" }
# multimint = { path = "../multimint" }
axum-otel-metrics = "0.8.0"
base64 = "0.22.0"
hex = "0.4.3"
fedimint-tbs = "0.3.0"
1 change: 1 addition & 0 deletions clientd-stateless/examples/cashu_encoding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

49 changes: 49 additions & 0 deletions clientd-stateless/src/error.rs
Original file line number Diff line number Diff line change
@@ -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<anyhow::Error>) -> 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<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self {
error: err.into(),
status: StatusCode::INTERNAL_SERVER_ERROR, // default status code
}
}
}
186 changes: 186 additions & 0 deletions clientd-stateless/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use std::path::PathBuf;
use std::str::FromStr;

use anyhow::Result;
use axum::http::Method;
use fedimint_core::api::InviteCode;
use router::handlers;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::info;

mod error;
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<String>,
}

// 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<AppState> {
Router::new()
.route("/keys", get(handlers::keys::handle_keys))
.route(
"/keys/:keyset_id",
get(handlers::keys::handle_keys_keyset_id),
)
.route("/keysets", get(handlers::keysets::handle_keysets))
.route("/swap", post(handlers::swap::handle_swap))
.route(
"/mint/quote/:method",
get(handlers::mint::quote::handle_method),
)
.route(
"/mint/quote/:method/:quote_id",
get(handlers::mint::quote::handle_method_quote_id),
)
.route("/mint/:method", post(handlers::mint::method::handle_method))
.route(
"/melt/quote/:method",
get(handlers::melt::quote::handle_method),
)
.route(
"/melt/quote/:method/:quote_id",
get(handlers::melt::quote::handle_method_quote_id),
)
.route("/melt/:method", post(handlers::melt::method::handle_method))
.route("/info", get(handlers::info::handle_info))
.route("/check", post(handlers::check::handle_check))
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,7 +45,11 @@ pub struct CashuNUT06InfoResponse {
pub async fn handle_info(
State(state): State<AppState>,
) -> Result<Json<CashuNUT06InfoResponse>, 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use tracing::{error, info};

use crate::error::AppError;
use crate::router::handlers::cashu::{Method, Unit};
use crate::router::handlers::{Method, Unit};
use crate::state::AppState;

#[derive(Debug, Deserialize)]
Expand Down
Loading
Loading