#![allow(missing_docs)]
use std::collections::{HashMap, HashSet};
use std::env::{self};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{anyhow, bail, Result};
use axum::Router;
use bip39::Mnemonic;
use cdk::cdk_database::{self, KVStore, MintDatabase, MintKeysDatabase};
use cdk::mint::{Mint, MintBuilder, MintMeltLimits};
use cdk::nuts::nut00::KnownMethod;
#[cfg(any(
feature = "cln",
feature = "lnbits",
feature = "lnd",
feature = "ldk-node",
feature = "fakewallet",
feature = "grpc-processor"
))]
use cdk::nuts::nut17::SupportedMethods;
use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path};
#[cfg(any(
feature = "cln",
feature = "lnbits",
feature = "lnd",
feature = "ldk-node"
))]
use cdk::nuts::CurrencyUnit;
use cdk::nuts::{
AuthRequired, ContactInfo, Method, MintVersion, PaymentMethod, ProtectedEndpoint, RoutePath,
};
use cdk_axum::cache::HttpCache;
use cdk_common::common::QuoteTTL;
use cdk_common::database::DynMintDatabase;
#[cfg(feature = "prometheus")]
use cdk_common::payment::MetricsMintPayment;
use cdk_common::payment::MintPayment;
#[cfg(feature = "postgres")]
use cdk_postgres::MintPgAuthDatabase;
#[cfg(feature = "postgres")]
use cdk_postgres::MintPgDatabase;
#[cfg(feature = "sqlite")]
use cdk_sqlite::mint::MintSqliteAuthDatabase;
#[cfg(feature = "sqlite")]
use cdk_sqlite::MintSqliteDatabase;
use cli::CLIArgs;
use config::{AuthType, DatabaseEngine, LnBackend};
use env_vars::ENV_WORK_DIR;
use setup::LnBackendSetup;
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::decompression::RequestDecompressionLayer;
use tower_http::trace::TraceLayer;
use tracing_appender::{non_blocking, rolling};
use tracing_subscriber::fmt::writer::MakeWriterExt;
use tracing_subscriber::EnvFilter;
#[cfg(feature = "swagger")]
use utoipa::OpenApi;
pub mod cli;
pub mod config;
pub mod env_vars;
pub mod setup;
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
const DEFAULT_BATCH_MINT_SIZE: u64 = 100;
fn extract_supported_payment_methods(mint_info: &cdk::nuts::MintInfo) -> Vec<String> {
let mut seen = HashSet::new();
mint_info
.nuts
.nut04
.methods
.iter()
.map(|method| method.method.to_string())
.filter(|method| seen.insert(method.clone()))
.collect()
}
#[cfg(feature = "cln")]
fn expand_path(path: &str) -> Option<PathBuf> {
if path.starts_with('~') {
if let Some(home_dir) = home::home_dir().as_mut() {
let remainder = &path[2..];
home_dir.push(remainder);
let expanded_path = home_dir;
Some(expanded_path.clone())
} else {
None
}
} else {
Some(PathBuf::from(path))
}
}
async fn initial_setup(
work_dir: &Path,
settings: &config::Settings,
db_password: Option<String>,
) -> Result<(
DynMintDatabase,
Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync>,
)> {
tracing::info!("Initializing database...");
let (localstore, keystore, kv) = setup_database(settings, work_dir, db_password).await?;
tracing::info!("Database initialized successfully");
Ok((localstore, keystore, kv))
}
pub fn setup_tracing(
work_dir: &Path,
logging_config: &config::LoggingConfig,
) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
let default_filter = "debug";
let hyper_filter = "hyper=warn,rustls=warn,reqwest=warn";
let h2_filter = "h2=warn";
let tower_filter = "tower=warn";
let tower_http = "tower_http=warn";
let rustls = "rustls=warn";
let tungstenite = "tungstenite=warn";
let tokio_postgres = "tokio_postgres=warn";
let env_filter = EnvFilter::new(format!(
"{default_filter},{hyper_filter},{h2_filter},{tower_filter},{tower_http},{rustls},{tungstenite},{tokio_postgres}"
));
use config::LoggingOutput;
match logging_config.output {
LoggingOutput::Stderr => {
let console_level = logging_config
.console_level
.as_deref()
.unwrap_or("info")
.parse::<tracing::Level>()
.unwrap_or(tracing::Level::INFO);
let stderr = std::io::stderr.with_max_level(console_level);
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_ansi(false)
.with_writer(stderr)
.init();
tracing::info!("Logging initialized: console only ({}+)", console_level);
Ok(None)
}
LoggingOutput::File => {
let file_level = logging_config
.file_level
.as_deref()
.unwrap_or("debug")
.parse::<tracing::Level>()
.unwrap_or(tracing::Level::DEBUG);
let logs_dir = work_dir.join("logs");
std::fs::create_dir_all(&logs_dir)?;
let file_appender = rolling::daily(&logs_dir, "cdk-mintd.log");
let (non_blocking_appender, guard) = non_blocking(file_appender);
let file_writer = non_blocking_appender.with_max_level(file_level);
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_ansi(false)
.with_writer(file_writer)
.init();
tracing::info!(
"Logging initialized: file only at {}/cdk-mintd.log ({}+)",
logs_dir.display(),
file_level
);
Ok(Some(guard))
}
LoggingOutput::Both => {
let console_level = logging_config
.console_level
.as_deref()
.unwrap_or("info")
.parse::<tracing::Level>()
.unwrap_or(tracing::Level::INFO);
let file_level = logging_config
.file_level
.as_deref()
.unwrap_or("debug")
.parse::<tracing::Level>()
.unwrap_or(tracing::Level::DEBUG);
let logs_dir = work_dir.join("logs");
std::fs::create_dir_all(&logs_dir)?;
let file_appender = rolling::daily(&logs_dir, "cdk-mintd.log");
let (non_blocking_appender, guard) = non_blocking(file_appender);
let stderr = std::io::stderr.with_max_level(console_level);
let file_writer = non_blocking_appender.with_max_level(file_level);
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_ansi(false)
.with_writer(stderr.and(file_writer))
.init();
tracing::info!(
"Logging initialized: console ({}+) and file at {}/cdk-mintd.log ({}+)",
console_level,
logs_dir.display(),
file_level
);
Ok(Some(guard))
}
}
}
pub async fn get_work_directory(args: &CLIArgs) -> Result<PathBuf> {
let work_dir = if let Some(work_dir) = &args.work_dir {
tracing::info!("Using work dir from cmd arg");
work_dir.clone()
} else if let Ok(env_work_dir) = env::var(ENV_WORK_DIR) {
tracing::info!("Using work dir from env var");
env_work_dir.into()
} else {
work_dir()?
};
tracing::info!("Using work dir: {}", work_dir.display());
Ok(work_dir)
}
pub fn load_settings(work_dir: &Path, config_path: Option<PathBuf>) -> Result<config::Settings> {
let config_file_arg = match config_path {
Some(c) => c,
None => work_dir.join("config.toml"),
};
let mut settings = if config_file_arg.exists() {
config::Settings::new(Some(config_file_arg))
} else {
tracing::info!("Config file does not exist. Attempting to read env vars");
config::Settings::default()
};
settings.from_env()
}
async fn setup_database(
settings: &config::Settings,
_work_dir: &Path,
_db_password: Option<String>,
) -> Result<(
DynMintDatabase,
Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync>,
)> {
tracing::info!("Using database engine: {:?}", settings.database.engine);
match settings.database.engine {
#[cfg(feature = "sqlite")]
DatabaseEngine::Sqlite => {
let db = setup_sqlite_database(_work_dir, _db_password).await?;
let localstore: Arc<dyn MintDatabase<cdk_database::Error> + Send + Sync> = db.clone();
let kv: Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync> = db.clone();
let keystore: Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync> = db;
Ok((localstore, keystore, kv))
}
#[cfg(feature = "postgres")]
DatabaseEngine::Postgres => {
let pg_config = settings.database.postgres.as_ref().ok_or_else(|| {
anyhow!("PostgreSQL configuration is required when using PostgreSQL engine")
})?;
if pg_config.url.is_empty() {
bail!("PostgreSQL URL is required. Set it in config file [database.postgres] section or via CDK_MINTD_POSTGRES_URL/CDK_MINTD_DATABASE_URL environment variable");
}
#[cfg(feature = "postgres")]
let pg_db = Arc::new(MintPgDatabase::new(pg_config.url.as_str()).await?);
tracing::info!("PostgreSQL database connection established");
#[cfg(feature = "postgres")]
let localstore: Arc<dyn MintDatabase<cdk_database::Error> + Send + Sync> =
pg_db.clone();
#[cfg(feature = "postgres")]
let kv: Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync> = pg_db.clone();
#[cfg(feature = "postgres")]
let keystore: Arc<
dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync,
> = pg_db;
#[cfg(feature = "postgres")]
return Ok((localstore, keystore, kv));
#[cfg(not(feature = "postgres"))]
bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.")
}
#[cfg(not(feature = "sqlite"))]
DatabaseEngine::Sqlite => {
bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.")
}
#[cfg(not(feature = "postgres"))]
DatabaseEngine::Postgres => {
bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.")
}
}
}
#[cfg(feature = "sqlite")]
async fn setup_sqlite_database(
work_dir: &Path,
_password: Option<String>,
) -> Result<Arc<MintSqliteDatabase>> {
let sql_db_path = work_dir.join("cdk-mintd.sqlite");
tracing::info!("SQLite database path: {}", sql_db_path.display());
#[cfg(not(feature = "sqlcipher"))]
let db = MintSqliteDatabase::new(&sql_db_path).await?;
#[cfg(feature = "sqlcipher")]
let db = {
let password = _password
.ok_or_else(|| anyhow!("Password required when sqlcipher feature is enabled"))?;
tracing::info!("Using SQLCipher encryption for SQLite database");
MintSqliteDatabase::new((sql_db_path, password)).await?
};
tracing::info!("SQLite database initialized successfully");
Ok(Arc::new(db))
}
async fn configure_mint_builder(
settings: &config::Settings,
mint_builder: MintBuilder,
runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
work_dir: &Path,
kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
) -> Result<MintBuilder> {
let mint_builder = configure_basic_info(settings, mint_builder);
let mint_builder =
configure_lightning_backend(settings, mint_builder, runtime, work_dir, kv_store).await?;
let mint_info = mint_builder.current_mint_info();
let payment_methods = extract_supported_payment_methods(&mint_info);
let mint_builder = mint_builder
.with_batch_minting(Some(DEFAULT_BATCH_MINT_SIZE), Some(payment_methods.clone()));
let mint_builder = configure_cache(settings, mint_builder, &payment_methods);
let mint_builder =
mint_builder.with_limits(settings.limits.max_inputs, settings.limits.max_outputs);
Ok(mint_builder)
}
fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) -> MintBuilder {
let mut contacts = Vec::new();
if let Some(nostr_key) = &settings.mint_info.contact_nostr_public_key {
if !nostr_key.is_empty() {
contacts.push(ContactInfo::new("nostr".to_string(), nostr_key.to_string()));
}
}
if let Some(email) = &settings.mint_info.contact_email {
if !email.is_empty() {
contacts.push(ContactInfo::new("email".to_string(), email.to_string()));
}
}
let mint_version = MintVersion::new(
"cdk-mintd".to_string(),
CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(),
);
let mut builder = mint_builder.with_version(mint_version);
if !settings.mint_info.name.is_empty() {
builder = builder.with_name(settings.mint_info.name.clone());
}
if !settings.mint_info.description.is_empty() {
builder = builder.with_description(settings.mint_info.description.clone());
}
if let Some(long_description) = &settings.mint_info.description_long {
if !long_description.is_empty() {
builder = builder.with_long_description(long_description.to_string());
}
}
for contact in contacts {
builder = builder.with_contact_info(contact);
}
if let Some(pubkey) = settings.mint_info.pubkey {
builder = builder.with_pubkey(pubkey);
}
if let Some(icon_url) = &settings.mint_info.icon_url {
if !icon_url.is_empty() {
builder = builder.with_icon_url(icon_url.to_string());
}
}
if let Some(motd) = &settings.mint_info.motd {
if !motd.is_empty() {
builder = builder.with_motd(motd.to_string());
}
}
if let Some(tos_url) = &settings.mint_info.tos_url {
if !tos_url.is_empty() {
builder = builder.with_tos_url(tos_url.to_string());
}
}
builder = builder.with_keyset_v2(settings.info.use_keyset_v2);
builder
}
async fn configure_lightning_backend(
settings: &config::Settings,
mut mint_builder: MintBuilder,
_runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
work_dir: &Path,
_kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
) -> Result<MintBuilder> {
let mint_melt_limits = MintMeltLimits {
mint_min: settings.ln.min_mint,
mint_max: settings.ln.max_mint,
melt_min: settings.ln.min_melt,
melt_max: settings.ln.max_melt,
};
tracing::debug!("Ln backend: {:?}", settings.ln.ln_backend);
match settings.ln.ln_backend {
#[cfg(feature = "cln")]
LnBackend::Cln => {
let cln_settings = settings
.cln
.clone()
.expect("Config checked at load that cln is some");
let cln = cln_settings
.setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store)
.await?;
#[cfg(feature = "prometheus")]
let cln = MetricsMintPayment::new(cln);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
CurrencyUnit::Sat,
mint_melt_limits,
Arc::new(cln),
)
.await?;
}
#[cfg(feature = "lnbits")]
LnBackend::LNbits => {
let lnbits_settings = settings.clone().lnbits.expect("Checked on config load");
let lnbits = lnbits_settings
.setup(settings, CurrencyUnit::Sat, None, work_dir, None)
.await?;
#[cfg(feature = "prometheus")]
let lnbits = MetricsMintPayment::new(lnbits);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
CurrencyUnit::Sat,
mint_melt_limits,
Arc::new(lnbits),
)
.await?;
}
#[cfg(feature = "lnd")]
LnBackend::Lnd => {
let lnd_settings = settings.clone().lnd.expect("Checked at config load");
let lnd = lnd_settings
.setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store)
.await?;
#[cfg(feature = "prometheus")]
let lnd = MetricsMintPayment::new(lnd);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
CurrencyUnit::Sat,
mint_melt_limits,
Arc::new(lnd),
)
.await?;
}
#[cfg(feature = "fakewallet")]
LnBackend::FakeWallet => {
let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined");
tracing::info!("Using fake wallet: {:?}", fake_wallet);
for unit in fake_wallet.clone().supported_units {
let fake = fake_wallet
.setup(settings, unit.clone(), None, work_dir, _kv_store.clone())
.await?;
#[cfg(feature = "prometheus")]
let fake = MetricsMintPayment::new(fake);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
unit.clone(),
mint_melt_limits,
Arc::new(fake),
)
.await?;
}
for rotation_cfg in &fake_wallet.keyset_rotations {
use cdk::mint::KeysetRotation;
let amounts = cdk::mint::UnitConfig::default().amounts;
let final_expiry = if rotation_cfg.expired {
Some(cdk::util::unix_time().saturating_sub(3600))
} else {
None
};
mint_builder = mint_builder.with_keyset_rotation(KeysetRotation {
unit: rotation_cfg.unit.clone(),
amounts,
input_fee_ppk: rotation_cfg.input_fee_ppk,
use_keyset_v2: rotation_cfg.version == "v2",
final_expiry,
});
}
}
#[cfg(feature = "grpc-processor")]
LnBackend::GrpcProcessor => {
let grpc_processor = settings
.clone()
.grpc_processor
.expect("grpc processor config defined");
tracing::info!(
"Attempting to start with gRPC payment processor at {}:{}.",
grpc_processor.addr,
grpc_processor.port
);
for unit in grpc_processor.clone().supported_units {
tracing::debug!("Adding unit: {:?}", unit);
let processor = grpc_processor
.setup(settings, unit.clone(), None, work_dir, None)
.await?;
#[cfg(feature = "prometheus")]
let processor = MetricsMintPayment::new(processor);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
unit.clone(),
mint_melt_limits,
Arc::new(processor),
)
.await?;
}
}
#[cfg(feature = "ldk-node")]
LnBackend::LdkNode => {
let ldk_node_settings = settings.clone().ldk_node.expect("Checked at config load");
tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings);
let ldk_node = ldk_node_settings
.setup(settings, CurrencyUnit::Sat, _runtime, work_dir, None)
.await?;
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
CurrencyUnit::Sat,
mint_melt_limits,
Arc::new(ldk_node),
)
.await?;
}
LnBackend::None => {
tracing::error!(
"Payment backend was not set or feature disabled. {:?}",
settings.ln.ln_backend
);
bail!("Lightning backend must be configured");
}
};
Ok(mint_builder)
}
async fn configure_backend_for_unit(
settings: &config::Settings,
mut mint_builder: MintBuilder,
unit: cdk::nuts::CurrencyUnit,
mint_melt_limits: MintMeltLimits,
backend: Arc<dyn MintPayment<Err = cdk_common::payment::Error> + Send + Sync>,
) -> Result<MintBuilder> {
let payment_settings = backend.get_settings().await?;
let mut methods = Vec::new();
if payment_settings.bolt11.is_some() {
methods.push(PaymentMethod::Known(KnownMethod::Bolt11));
}
if payment_settings.bolt12.is_some() {
methods.push(PaymentMethod::Known(KnownMethod::Bolt12));
}
for method_name in payment_settings.custom.keys() {
methods.push(PaymentMethod::from(method_name.as_str()));
}
for method in &methods {
mint_builder
.add_payment_processor(
unit.clone(),
method.clone(),
mint_melt_limits,
backend.clone(),
)
.await?;
}
for method in &methods {
let method_str = method.to_string();
let nut17_supported = match method_str.as_str() {
"bolt11" => SupportedMethods::default_bolt11(unit.clone()),
"bolt12" => SupportedMethods::default_bolt12(unit.clone()),
_ => SupportedMethods::default_custom(method.clone(), unit.clone()),
};
mint_builder = mint_builder.with_supported_websockets(nut17_supported);
}
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder.set_unit_fee(&unit, input_fee)?;
}
Ok(mint_builder)
}
fn configure_cache(
settings: &config::Settings,
mint_builder: MintBuilder,
payment_methods: &[String],
) -> MintBuilder {
let mut cached_endpoints = vec![
CachedEndpoint::new(NUT19Method::Post, NUT19Path::Swap),
];
for method in payment_methods {
cached_endpoints.push(CachedEndpoint::new(
NUT19Method::Post,
NUT19Path::custom_mint(method),
));
cached_endpoints.push(CachedEndpoint::new(
NUT19Method::Post,
NUT19Path::custom_melt(method),
));
}
let cache: HttpCache = settings.info.http_cache.clone().into();
mint_builder.with_cache(Some(cache.ttl.as_secs()), cached_endpoints)
}
async fn setup_authentication(
settings: &config::Settings,
_work_dir: &Path,
mut mint_builder: MintBuilder,
_password: Option<String>,
) -> Result<(
MintBuilder,
Option<cdk_common::database::DynMintAuthDatabase>,
)> {
if let Some(auth_settings) = settings.auth.clone() {
use cdk_common::database::DynMintAuthDatabase;
tracing::info!("Auth settings are defined. {:?}", auth_settings);
let auth_localstore: DynMintAuthDatabase = match settings.database.engine {
#[cfg(feature = "sqlite")]
DatabaseEngine::Sqlite => {
#[cfg(feature = "sqlite")]
{
let sql_db_path = _work_dir.join("cdk-mintd-auth.sqlite");
#[cfg(not(feature = "sqlcipher"))]
let sqlite_db = MintSqliteAuthDatabase::new(&sql_db_path).await?;
#[cfg(feature = "sqlcipher")]
let sqlite_db = {
let password = _password.clone().ok_or_else(|| {
anyhow!("Password required when sqlcipher feature is enabled")
})?;
MintSqliteAuthDatabase::new((sql_db_path, password)).await?
};
Arc::new(sqlite_db)
}
#[cfg(not(feature = "sqlite"))]
{
bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.")
}
}
#[cfg(feature = "postgres")]
DatabaseEngine::Postgres => {
#[cfg(feature = "postgres")]
{
let auth_db_config = settings.auth_database.as_ref().ok_or_else(|| {
anyhow!("Auth database configuration is required when using PostgreSQL with authentication. Set [auth_database] section in config file or CDK_MINTD_AUTH_POSTGRES_URL environment variable")
})?;
let auth_pg_config = auth_db_config.postgres.as_ref().ok_or_else(|| {
anyhow!("PostgreSQL auth database configuration is required when using PostgreSQL with authentication. Set [auth_database.postgres] section in config file or CDK_MINTD_AUTH_POSTGRES_URL environment variable")
})?;
if auth_pg_config.url.is_empty() {
bail!("Auth database PostgreSQL URL is required and cannot be empty. Set it in config file [auth_database.postgres] section or via CDK_MINTD_AUTH_POSTGRES_URL environment variable");
}
Arc::new(MintPgAuthDatabase::new(auth_pg_config.url.as_str()).await?)
}
#[cfg(not(feature = "postgres"))]
{
bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.")
}
}
#[cfg(not(feature = "sqlite"))]
DatabaseEngine::Sqlite => {
bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.")
}
#[cfg(not(feature = "postgres"))]
DatabaseEngine::Postgres => {
bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.")
}
};
let mut protected_endpoints = HashMap::new();
let mut blind_auth_endpoints = vec![];
let mut clear_auth_endpoints = vec![];
let mut unprotected_endpoints = vec![];
let mint_blind_auth_endpoint =
ProtectedEndpoint::new(Method::Post, RoutePath::MintBlindAuth);
protected_endpoints.insert(mint_blind_auth_endpoint.clone(), AuthRequired::Clear);
clear_auth_endpoints.push(mint_blind_auth_endpoint);
let mut add_endpoint = |endpoint: ProtectedEndpoint, auth_type: &AuthType| {
match auth_type {
AuthType::Blind => {
protected_endpoints.insert(endpoint.clone(), AuthRequired::Blind);
blind_auth_endpoints.push(endpoint);
}
AuthType::Clear => {
protected_endpoints.insert(endpoint.clone(), AuthRequired::Clear);
clear_auth_endpoints.push(endpoint);
}
AuthType::None => {
unprotected_endpoints.push(endpoint);
}
};
};
{
let swap_protected_endpoint = ProtectedEndpoint::new(Method::Post, RoutePath::Swap);
add_endpoint(swap_protected_endpoint, &auth_settings.swap);
}
{
let restore_protected_endpoint =
ProtectedEndpoint::new(Method::Post, RoutePath::Restore);
add_endpoint(restore_protected_endpoint, &auth_settings.restore);
}
{
let state_protected_endpoint =
ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate);
add_endpoint(state_protected_endpoint, &auth_settings.check_proof_state);
}
{
let ws_protected_endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Ws);
add_endpoint(ws_protected_endpoint, &auth_settings.websocket_auth);
}
mint_builder = mint_builder.with_auth(
auth_localstore.clone(),
auth_settings.openid_discovery,
auth_settings.openid_client_id,
clear_auth_endpoints,
);
mint_builder =
mint_builder.with_blind_auth(auth_settings.mint_max_bat, blind_auth_endpoints);
let mut tx = auth_localstore.begin_transaction().await?;
tx.remove_protected_endpoints(unprotected_endpoints).await?;
tx.add_protected_endpoints(protected_endpoints).await?;
tx.commit().await?;
Ok((mint_builder, Some(auth_localstore)))
} else {
Ok((mint_builder, None))
}
}
async fn build_mint(
settings: &config::Settings,
keystore: Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
mint_builder: MintBuilder,
) -> Result<Mint> {
if let Some(signatory_url) = settings.info.signatory_url.clone() {
tracing::info!(
"Connecting to remote signatory to {} with certs {:?}",
signatory_url,
settings.info.signatory_certs.clone()
);
Ok(mint_builder
.build_with_signatory(Arc::new(
cdk_signatory::SignatoryRpcClient::new(
signatory_url,
settings.info.signatory_certs.clone(),
)
.await?,
))
.await?)
} else if let Some(seed) = settings.info.seed.clone() {
let seed_bytes: Vec<u8> = seed.into();
Ok(mint_builder.build_with_seed(keystore, &seed_bytes).await?)
} else if let Some(mnemonic) = settings
.info
.mnemonic
.clone()
.map(|s| Mnemonic::from_str(&s))
.transpose()?
{
Ok(mint_builder
.build_with_seed(keystore, &mnemonic.to_seed_normalized(""))
.await?)
} else {
bail!("No seed nor remote signatory set");
}
}
async fn start_services_with_shutdown(
mint: Arc<cdk::mint::Mint>,
settings: &config::Settings,
_work_dir: &Path,
mint_builder_info: cdk::nuts::MintInfo,
shutdown_signal: impl std::future::Future<Output = ()> + Send + 'static,
routers: Vec<Router>,
auth_localstore: Option<cdk_common::database::DynMintAuthDatabase>,
) -> Result<()> {
let listen_addr = settings.info.listen_host.clone();
let listen_port = settings.info.listen_port;
let cache: HttpCache = settings.info.http_cache.clone().into();
#[cfg(feature = "management-rpc")]
let mut rpc_enabled = false;
#[cfg(not(feature = "management-rpc"))]
let rpc_enabled = false;
#[cfg(feature = "management-rpc")]
let mut rpc_server: Option<cdk_mint_rpc::MintRPCServer> = None;
#[cfg(feature = "management-rpc")]
{
if let Some(rpc_settings) = settings.mint_management_rpc.clone() {
if rpc_settings.enabled {
let addr = rpc_settings.address.unwrap_or("127.0.0.1".to_string());
let port = rpc_settings.port.unwrap_or(8086);
let mut mint_rpc = cdk_mint_rpc::MintRPCServer::new(&addr, port, mint.clone())?;
let tls_dir = rpc_settings.tls_dir_path.unwrap_or(_work_dir.join("tls"));
let tls_dir = if tls_dir.exists() {
Some(tls_dir)
} else {
tracing::warn!(
"TLS directory does not exist: {}. Starting RPC server in INSECURE mode without TLS encryption",
tls_dir.display()
);
None
};
mint_rpc.start(tls_dir).await?;
rpc_server = Some(mint_rpc);
rpc_enabled = true;
}
}
}
let desired_quote_ttl: QuoteTTL = settings.info.quote_ttl.unwrap_or_default();
if rpc_enabled {
if mint.mint_info().await.is_err() {
tracing::info!("Mint info not set on mint, setting.");
mint.set_mint_info(mint_builder_info).await?;
mint.set_quote_ttl(desired_quote_ttl).await?;
} else {
if !mint.quote_ttl_is_persisted().await? {
mint.set_quote_ttl(desired_quote_ttl).await?;
}
let mint_version = MintVersion::new(
"cdk-mintd".to_string(),
CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(),
);
let mut stored_mint_info = mint.mint_info().await?;
stored_mint_info.version = Some(mint_version);
mint.set_mint_info(stored_mint_info).await?;
tracing::info!("Mint info already set, not using config file settings.");
}
} else {
tracing::info!("RPC not enabled, using mint info and quote TTL from config.");
let mut mint_builder_info = mint_builder_info;
if let Ok(mint_info) = mint.mint_info().await {
if mint_builder_info.pubkey.is_none() {
mint_builder_info.pubkey = mint_info.pubkey;
}
}
mint.set_mint_info(mint_builder_info).await?;
mint.set_quote_ttl(desired_quote_ttl).await?;
}
let mint_info = mint.mint_info().await?;
let nut04_methods = mint_info.nuts.nut04.supported_methods();
let nut05_methods = mint_info.nuts.nut05.supported_methods();
let mut custom_methods = mint.get_custom_payment_methods().await?;
let bolt11_method = PaymentMethod::Known(KnownMethod::Bolt11);
let bolt11_supported =
nut04_methods.contains(&&bolt11_method) || nut05_methods.contains(&&bolt11_method);
let bolt12_method = PaymentMethod::Known(KnownMethod::Bolt12);
let bolt12_supported =
nut04_methods.contains(&&bolt12_method) || nut05_methods.contains(&&bolt12_method);
if bolt11_supported
&& !custom_methods.contains(&PaymentMethod::Known(KnownMethod::Bolt11).to_string())
{
custom_methods.push(PaymentMethod::Known(KnownMethod::Bolt11).to_string());
}
if bolt12_supported
&& !custom_methods.contains(&PaymentMethod::Known(KnownMethod::Bolt12).to_string())
{
custom_methods.push(PaymentMethod::Known(KnownMethod::Bolt12).to_string());
}
tracing::info!("Payment methods: {:?}", custom_methods);
if let (Some(ref auth_settings), Some(auth_db)) = (&settings.auth, &auth_localstore) {
if auth_settings.auth_enabled {
use std::collections::HashMap;
use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
use cdk::nuts::AuthRequired;
use crate::config::AuthType;
let existing_endpoints = auth_db.get_auth_for_endpoints().await?;
let payment_method_endpoints_to_remove: Vec<ProtectedEndpoint> = existing_endpoints
.keys()
.filter(|endpoint| {
matches!(
endpoint.path,
RoutePath::MintQuote(_)
| RoutePath::Mint(_)
| RoutePath::MeltQuote(_)
| RoutePath::Melt(_)
)
})
.cloned()
.collect();
if !payment_method_endpoints_to_remove.is_empty() {
tracing::debug!(
"Removing {} old payment method endpoints from database",
payment_method_endpoints_to_remove.len()
);
let mut tx = auth_db.begin_transaction().await?;
tx.remove_protected_endpoints(payment_method_endpoints_to_remove)
.await?;
tx.commit().await?;
}
if !custom_methods.is_empty() {
let mut protected_endpoints = HashMap::new();
for method_name in &custom_methods {
tracing::debug!("Adding auth endpoints for payment method: {}", method_name);
let mint_quote_auth = match auth_settings.get_mint_quote {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
let check_mint_quote_auth = match auth_settings.check_mint_quote {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
let mint_auth = match auth_settings.mint {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
let melt_quote_auth = match auth_settings.get_melt_quote {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
let check_melt_quote_auth = match auth_settings.check_melt_quote {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
let melt_auth = match auth_settings.melt {
AuthType::Clear => Some(AuthRequired::Clear),
AuthType::Blind => Some(AuthRequired::Blind),
AuthType::None => None,
};
if let Some(auth) = mint_quote_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Post,
RoutePath::MintQuote(method_name.clone()),
),
auth,
);
}
if let Some(auth) = check_mint_quote_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Get,
RoutePath::MintQuote(method_name.clone()),
),
auth,
);
}
if let Some(auth) = mint_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Post,
RoutePath::Mint(method_name.clone()),
),
auth,
);
}
if let Some(auth) = melt_quote_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Post,
RoutePath::MeltQuote(method_name.clone()),
),
auth,
);
}
if let Some(auth) = check_melt_quote_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Get,
RoutePath::MeltQuote(method_name.clone()),
),
auth,
);
}
if let Some(auth) = melt_auth {
protected_endpoints.insert(
ProtectedEndpoint::new(
Method::Post,
RoutePath::Melt(method_name.clone()),
),
auth,
);
}
}
if !protected_endpoints.is_empty() {
let mut tx = auth_db.begin_transaction().await?;
tx.add_protected_endpoints(protected_endpoints).await?;
tx.commit().await?;
}
}
}
}
let v1_service = cdk_axum::create_mint_router_with_custom_cache(
Arc::clone(&mint),
cache,
custom_methods,
settings.info.enable_info_page.unwrap_or(true),
)
.await?;
let mut mint_service = Router::new()
.merge(v1_service)
.layer(
ServiceBuilder::new()
.layer(RequestDecompressionLayer::new())
.layer(CompressionLayer::new()),
)
.layer(TraceLayer::new_for_http());
for router in routers {
mint_service = mint_service.merge(router);
}
#[cfg(feature = "swagger")]
{
if settings.info.enable_swagger_ui.unwrap_or(false) {
mint_service = mint_service.merge(
utoipa_swagger_ui::SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", cdk_axum::ApiDoc::openapi()),
);
}
}
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
#[cfg(feature = "prometheus")]
let prometheus_handle = {
if let Some(prometheus_settings) = &settings.prometheus {
if prometheus_settings.enabled {
let addr = prometheus_settings
.address
.clone()
.unwrap_or("127.0.0.1".to_string());
let port = prometheus_settings.port.unwrap_or(9000);
let address = format!("{}:{}", addr, port)
.parse()
.expect("Invalid prometheus address");
let server = cdk_prometheus::PrometheusBuilder::new()
.bind_address(address)
.build_with_cdk_metrics()?;
let mut shutdown_rx = shutdown_tx.subscribe();
let prometheus_shutdown = async move {
let _ = shutdown_rx.recv().await;
};
Some(tokio::spawn(async move {
if let Err(e) = server.start(prometheus_shutdown).await {
tracing::error!("Failed to start prometheus server: {}", e);
}
}))
} else {
None
}
} else {
None
}
};
mint.start().await?;
let socket_addr = SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))?;
let listener = tokio::net::TcpListener::bind(socket_addr).await?;
tracing::info!("listening on {}", listener.local_addr()?);
let shutdown_broadcast_task = {
let shutdown_tx = shutdown_tx.clone();
tokio::spawn(async move {
shutdown_signal.await;
tracing::info!("Shutdown signal received, broadcasting to all services");
let _ = shutdown_tx.send(());
})
};
let mut axum_shutdown_rx = shutdown_tx.subscribe();
let axum_shutdown = async move {
let _ = axum_shutdown_rx.recv().await;
};
let axum_result = axum::serve(listener, mint_service).with_graceful_shutdown(axum_shutdown);
match axum_result.await {
Ok(_) => {
tracing::info!("Axum server stopped with okay status");
}
Err(err) => {
tracing::warn!("Axum server stopped with error");
tracing::error!("{}", err);
bail!("Axum exited with error")
}
}
let _ = shutdown_broadcast_task.await;
#[cfg(feature = "prometheus")]
if let Some(handle) = prometheus_handle {
if let Err(e) = handle.await {
tracing::warn!("Prometheus server task failed: {}", e);
}
}
mint.stop().await?;
#[cfg(feature = "management-rpc")]
{
if let Some(rpc_server) = rpc_server {
rpc_server.stop().await?;
}
}
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to install CTRL+C handler");
tracing::info!("Shutdown signal received");
}
fn work_dir() -> Result<PathBuf> {
let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?;
let dir = home_dir.join(".cdk-mintd");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub async fn run_mintd(
work_dir: &Path,
settings: &config::Settings,
db_password: Option<String>,
enable_logging: bool,
runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
routers: Vec<Router>,
) -> Result<()> {
let _guard = if enable_logging {
setup_tracing(work_dir, &settings.info.logging)?
} else {
None
};
let result = run_mintd_with_shutdown(
work_dir,
settings,
shutdown_signal(),
db_password,
runtime,
routers,
)
.await;
if let Some(guard) = _guard {
tracing::info!("Shutting down logging worker thread");
drop(guard);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
tracing::info!("Mintd shutdown");
result
}
pub async fn run_mintd_with_shutdown(
work_dir: &Path,
settings: &config::Settings,
shutdown_signal: impl std::future::Future<Output = ()> + Send + 'static,
db_password: Option<String>,
runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
routers: Vec<Router>,
) -> Result<()> {
let (localstore, keystore, kv) = initial_setup(work_dir, settings, db_password.clone()).await?;
let mint_builder = MintBuilder::new(localstore);
let maybe_mint_builder = {
#[cfg(feature = "management-rpc")]
{
if let Some(rpc_settings) = settings.mint_management_rpc.clone() {
if rpc_settings.enabled {
let mut tmp = mint_builder;
if let Err(e) = tmp.init_from_db_if_present().await {
tracing::warn!("Failed to init builder from DB: {}", e);
}
tmp
} else {
mint_builder
}
} else {
mint_builder
}
}
#[cfg(not(feature = "management-rpc"))]
{
mint_builder
}
};
let mint_builder =
configure_mint_builder(settings, maybe_mint_builder, runtime, work_dir, Some(kv)).await?;
let (mint_builder, auth_localstore) =
setup_authentication(settings, work_dir, mint_builder, db_password).await?;
let config_mint_info = mint_builder.current_mint_info();
let mint = build_mint(settings, keystore, mint_builder).await?;
tracing::debug!("Mint built from builder.");
let mint = Arc::new(mint);
start_services_with_shutdown(
mint.clone(),
settings,
work_dir,
config_mint_info,
shutdown_signal,
routers,
auth_localstore,
)
.await
}
#[cfg(test)]
mod tests {
use cdk::nuts::{CurrencyUnit, MintMethodSettings, PaymentMethod};
use super::*;
#[test]
fn test_postgres_auth_url_validation() {
let auth_config = config::PostgresAuthConfig {
url: "".to_string(),
..Default::default()
};
assert!(auth_config.url.is_empty());
let auth_config = config::PostgresAuthConfig {
url: "postgresql://user:password@localhost:5432/auth_db".to_string(),
..Default::default()
};
assert!(!auth_config.url.is_empty());
}
#[test]
fn test_extract_supported_payment_methods_unique_ordered() {
let mut mint_info = cdk::nuts::MintInfo::default();
mint_info.nuts.nut04.methods = vec![
MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt11),
unit: CurrencyUnit::Sat,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt12),
unit: CurrencyUnit::Sat,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt11),
unit: CurrencyUnit::Msat,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Custom("paypal".to_string()),
unit: CurrencyUnit::Usd,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Custom("paypal".to_string()),
unit: CurrencyUnit::Eur,
min_amount: None,
max_amount: None,
options: None,
},
];
let methods = extract_supported_payment_methods(&mint_info);
assert_eq!(methods, vec!["bolt11", "bolt12", "paypal"]);
}
}