Skip to content
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
2 changes: 2 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
pub mod resolvers;
pub mod rest;
pub mod websockets;

// Re-export commonly used items
pub use resolvers::LnurlResolver;
pub use rest as handlers; // For backward compatibility
pub use websockets::websocket_handler;
3 changes: 3 additions & 0 deletions src/api/resolvers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod payment;

pub use payment::LnurlResolver;
131 changes: 131 additions & 0 deletions src/api/resolvers/payment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use std::str::FromStr;

use anyhow::{bail, Context};
use async_trait::async_trait;
use fedimint_core::Amount;
use fedimint_ln_common::lightning_invoice::Bolt11Invoice;
use tracing::debug;

use crate::core::PaymentInfoResolver;
use crate::error::AppError;
use crate::observability::sanitize_invoice;

/// LNURL resolver implementation for the API layer
/// Handles LNURL and Lightning Address resolution to Bolt11 invoices
pub struct LnurlResolver {
http_client: reqwest::Client,
}

impl LnurlResolver {
pub fn new() -> Self {
Self {
http_client: reqwest::Client::new(),
}
}
}

impl Default for LnurlResolver {
fn default() -> Self {
Self::new()
}
}

#[async_trait]
impl PaymentInfoResolver for LnurlResolver {
async fn resolve_payment_info(
&self,
payment_info: &str,
amount_msat: Option<Amount>,
lnurl_comment: Option<&str>,
) -> Result<Option<String>, AppError> {
let info = payment_info.trim();

// First check if it's already a Bolt11 invoice
if let Ok(invoice) = Bolt11Invoice::from_str(info) {
debug!(
"Payment info is already a bolt11 invoice: {}",
sanitize_invoice(&invoice)
);

// Validate amount constraints
match (invoice.amount_milli_satoshis(), amount_msat) {
(Some(_), Some(_)) => {
return Err(AppError::validation_error(
"Amount specified in both invoice and request",
));
}
(None, _) => {
return Err(AppError::validation_error(
"Invoices without amounts are not supported",
));
}
_ => {}
}

// Return None to indicate no resolution needed - use original payment_info
return Ok(None);
}

// Try to parse as LNURL or Lightning Address
let lnurl = if info.to_lowercase().starts_with("lnurl") {
lnurl::lnurl::LnUrl::from_str(info)
.map_err(|e| AppError::validation_error(format!("Invalid LNURL: {}", e)))?
} else if info.contains('@') {
lnurl::lightning_address::LightningAddress::from_str(info)
.map_err(|e| {
AppError::validation_error(format!("Invalid Lightning Address: {}", e))
})?
.lnurl()
} else {
// Not LNURL or Lightning Address, return None to try as Bolt11
return Ok(None);
};

debug!("Parsed payment info as LNURL: {:?}", lnurl);

let amount = amount_msat
.context("Amount must be specified when using LNURL or Lightning Address")
.map_err(|e| AppError::validation_error(e.to_string()))?;

// Create LNURL client
let async_client = lnurl::AsyncClient::from_client(self.http_client.clone());

// Make LNURL request
let response = async_client
.make_request(&lnurl.url)
.await
.map_err(|e| AppError::gateway_error(format!("LNURL request failed: {}", e)))?;

match response {
lnurl::LnUrlResponse::LnUrlPayResponse(pay_response) => {
// Get the invoice from the LNURL service
let invoice_response = async_client
.get_invoice(&pay_response, amount.msats, None, lnurl_comment)
.await
.map_err(|e| {
AppError::gateway_error(format!("Failed to get invoice from LNURL: {}", e))
})?;

// Validate the returned invoice
let invoice = Bolt11Invoice::from_str(invoice_response.invoice()).map_err(|e| {
AppError::validation_error(format!("Invalid invoice from LNURL: {}", e))
})?;

// Verify amount matches
if invoice.amount_milli_satoshis() != Some(amount.msats) {
return Err(AppError::validation_error(format!(
"LNURL returned invoice with wrong amount. Expected {} msat, got {:?}",
amount.msats,
invoice.amount_milli_satoshis()
)));
}

Ok(Some(invoice.to_string()))
}
other => Err(AppError::validation_error(format!(
"Unexpected LNURL response type: {:?}",
other
))),
}
}
}
47 changes: 4 additions & 43 deletions src/api/rest/admin/info.rs
Original file line number Diff line number Diff line change
@@ -1,55 +1,16 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;

use anyhow::Error;
use axum::extract::State;
use axum::Json;
use fedimint_core::config::FederationId;
use fedimint_core::{Amount, TieredCounts};
use fedimint_mint_client::MintClientModule;
use fedimint_wallet_client::WalletClientModule;
use serde::Serialize;
use serde_json::{json, Value};

use crate::core::multimint::MultiMint;
use crate::core::InfoResponse;
use crate::error::AppError;
use crate::state::AppState;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InfoResponse {
pub network: String,
pub meta: BTreeMap<String, String>,
pub total_amount_msat: Amount,
pub total_num_notes: usize,
pub denominations_msat: TieredCounts,
}

async fn _info(multimint: MultiMint) -> Result<HashMap<FederationId, InfoResponse>, Error> {
let mut info = HashMap::new();

for (id, client) in multimint.clients.lock().await.iter() {
let mint_client = client.get_first_module::<MintClientModule>()?;
let wallet_client = client.get_first_module::<WalletClientModule>()?;
let mut dbtx = client.db().begin_transaction_nc().await;
let summary = mint_client.get_note_counts_by_denomination(&mut dbtx).await;

info.insert(
*id,
InfoResponse {
network: wallet_client.get_network().to_string(),
meta: client.config().await.global.meta.clone(),
total_amount_msat: summary.total_amount(),
total_num_notes: summary.count_items(),
denominations_msat: summary,
},
);
}

Ok(info)
}

pub async fn handle_ws(state: AppState, _v: Value) -> Result<Value, AppError> {
let info = _info(state.multimint().clone()).await?;
let info = state.core.get_info().await?;
let info_json = json!(info);
Ok(info_json)
}
Expand All @@ -58,6 +19,6 @@ pub async fn handle_ws(state: AppState, _v: Value) -> Result<Value, AppError> {
pub async fn handle_rest(
State(state): State<AppState>,
) -> Result<Json<HashMap<FederationId, InfoResponse>>, AppError> {
let info = _info(state.multimint().clone()).await?;
let info = state.core.get_info().await?;
Ok(Json(info))
}
106 changes: 13 additions & 93 deletions src/api/rest/admin/join.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
use anyhow::{anyhow, Error};
use anyhow::anyhow;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::Utc;
use fedimint_core::config::FederationId;
use fedimint_core::invite_code::InviteCode;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{info, instrument};

use crate::core::multimint::MultiMint;
use crate::core::JoinFederationResponse;
use crate::error::AppError;
use crate::events::FmcdEvent;
use crate::observability::correlation::RequestContext;
use crate::state::AppState;

Expand All @@ -21,101 +17,25 @@ pub struct JoinRequest {
pub invite_code: InviteCode,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinResponse {
pub this_federation_id: FederationId,
pub federation_ids: Vec<FederationId>,
}

#[instrument(
skip(multimint, state),
fields(
federation_id = %req.invite_code.federation_id(),
federation_id_str = tracing::field::Empty,
)
)]
async fn _join(
mut multimint: MultiMint,
req: JoinRequest,
state: &AppState,
context: Option<RequestContext>,
) -> Result<JoinResponse, Error> {
let span = tracing::Span::current();
let federation_id = req.invite_code.federation_id();
span.record("federation_id_str", &federation_id.to_string());

info!(
federation_id = %federation_id,
"Joining federation"
);

let this_federation_id = multimint
.register_new(req.invite_code.clone())
.await
.map_err(|e| {
// Emit federation connection failed event
let event_bus = state.event_bus().clone();
let federation_id_str = federation_id.to_string();
let correlation_id = context.as_ref().map(|c| c.correlation_id.clone());
let error_msg = e.to_string();

tokio::spawn(async move {
let event = FmcdEvent::FederationDisconnected {
federation_id: federation_id_str,
reason: format!("Failed to join: {}", error_msg),
correlation_id,
timestamp: Utc::now(),
};
let _ = event_bus.publish(event).await;
});

e
})?;

// Emit federation connection success event
let event_bus = state.event_bus().clone();
let federation_id_str = this_federation_id.to_string();
let correlation_id = context.as_ref().map(|c| c.correlation_id.clone());
tokio::spawn(async move {
let event = FmcdEvent::FederationConnected {
federation_id: federation_id_str,
correlation_id,
timestamp: Utc::now(),
};
let _ = event_bus.publish(event).await;
});

let federation_ids = multimint.ids().await.into_iter().collect::<Vec<_>>();

info!(
federation_id = %this_federation_id,
total_federations = federation_ids.len(),
"Successfully joined federation"
);

Ok(JoinResponse {
this_federation_id,
federation_ids,
})
}

pub async fn handle_ws(state: AppState, v: Value) -> Result<Value, AppError> {
let v = serde_json::from_value::<JoinRequest>(v)
let req = serde_json::from_value::<JoinRequest>(v)
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, anyhow!("Invalid request: {}", e)))?;
// TODO: WebSocket requests should get RequestContext from middleware
let context = Some(RequestContext::new(None));
let join = _join(state.multimint().clone(), v, &state, context).await?;
let join_json = json!(join);
Ok(join_json)
let response = state.core.join_federation(req.invite_code, context).await?;
let response_json = json!(response);
Ok(response_json)
}

#[axum_macros::debug_handler]
pub async fn handle_rest(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(req): Json<JoinRequest>,
) -> Result<Json<JoinResponse>, AppError> {
let join = _join(state.multimint().clone(), req, &state, Some(context)).await?;
Ok(Json(join))
) -> Result<Json<JoinFederationResponse>, AppError> {
let response = state
.core
.join_federation(req.invite_code, Some(context))
.await?;
Ok(Json(response))
}
19 changes: 0 additions & 19 deletions src/api/rest/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,3 @@ pub mod module;
pub mod operations;
pub mod restore;
pub mod version;

use fedimint_client::ClientHandleArc;
use fedimint_mint_client::MintClientModule;
use fedimint_wallet_client::WalletClientModule;
use info::InfoResponse;

pub async fn _get_note_summary(client: &ClientHandleArc) -> anyhow::Result<InfoResponse> {
let mint_client = client.get_first_module::<MintClientModule>()?;
let wallet_client = client.get_first_module::<WalletClientModule>()?;
let mut dbtx = client.db().begin_transaction_nc().await;
let summary = mint_client.get_note_counts_by_denomination(&mut dbtx).await;
Ok(InfoResponse {
network: wallet_client.get_network().to_string(),
meta: client.config().await.global.meta.clone(),
total_amount_msat: summary.total_amount(),
total_num_notes: summary.count_items(),
denominations_msat: summary,
})
}
Loading
Loading