#![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, Context, Result};
use axum::extract::DefaultBodyLimit;
use axum::Router;
use bip39::Mnemonic;
use cdk::cdk_database::{self, KVStore, KVStoreCompareAndSwap, MintDatabase, MintKeysDatabase};
use cdk::mint::{Mint, MintBuilder, MintMeltLimits};
use cdk::nuts::nut00::KnownMethod;
#[cfg(any(
feature = "cln",
feature = "lnd",
feature = "ldk-node",
feature = "fakewallet",
feature = "bdk",
feature = "grpc-processor"
))]
use cdk::nuts::nut17::SupportedMethods;
use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path};
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, MintPgDatabase, PgConfig};
#[cfg(feature = "sqlite")]
use cdk_sqlite::mint::MintSqliteAuthDatabase;
#[cfg(feature = "sqlite")]
use cdk_sqlite::MintSqliteDatabase;
use cli::CLIArgs;
use config::{AuthType, DatabaseEngine, PaymentBackendType};
use env_vars::ENV_WORK_DIR;
use setup::PaymentBackendSetup;
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;
pub mod cli;
pub mod config;
mod config_migration;
mod config_service;
mod config_store;
pub mod env_vars;
mod secret;
pub mod setup;
pub use config_migration::{migrate_legacy_configuration, MigrationOutcome};
pub use config_service::{ApplyOutcome, RollbackOutcome};
#[cfg(test)]
pub(crate) mod test_utils {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
pub(crate) fn unique_temp_path(name: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
std::env::temp_dir().join(format!(
"{name}_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
}
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
const DEFAULT_BATCH_MINT_SIZE: u64 = 100;
const REQUEST_BODY_LIMIT_BYTES: usize = 1_048_576;
type DynSignatory = Arc<dyn cdk_signatory::signatory::Signatory + Send + Sync>;
#[derive(Clone)]
struct ValidatedSigningSource {
expected_pubkey: cdk::nuts::PublicKey,
remote_signatory: Option<DynSignatory>,
}
impl std::fmt::Debug for ValidatedSigningSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidatedSigningSource")
.field("expected_pubkey", &self.expected_pubkey)
.field("remote_signatory", &self.remote_signatory.is_some())
.finish()
}
}
#[derive(Debug, Clone)]
struct ConfigurationActivation {
service: config_service::ConfigurationService,
phase: ActivationPhase,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActivationPhase {
Pending {
revision: u64,
},
Applied,
}
impl ConfigurationActivation {
fn new(
service: config_service::ConfigurationService,
state: config_store::DocumentState,
revision: u64,
) -> Self {
let phase = match state {
config_store::DocumentState::Pending => ActivationPhase::Pending { revision },
config_store::DocumentState::Applied => ActivationPhase::Applied,
};
Self { service, phase }
}
fn forces_configuration(&self) -> bool {
matches!(self.phase, ActivationPhase::Pending { .. })
}
fn preserves_database_values(&self, rpc_enabled: bool) -> bool {
rpc_enabled && !self.forces_configuration()
}
async fn mark_applied(&self) -> Result<()> {
if let ActivationPhase::Pending { revision } = self.phase {
if !self.service.mark_applied(revision).await? {
tracing::info!(
"A newer configuration was stored during startup and remains unapplied for the next restart."
);
}
}
Ok(())
}
}
#[cfg(feature = "management-rpc")]
#[derive(Debug, Clone)]
struct ConfigurationMutationGuard {
service: config_service::ConfigurationService,
}
#[cfg(feature = "management-rpc")]
#[async_trait::async_trait]
impl cdk_mint_rpc::MintMutationGuard for ConfigurationMutationGuard {
async fn check(&self) -> Result<(), cdk_mint_rpc::MintMutationGuardError> {
match self.service.has_pending_configuration().await {
Ok(true) => Err(cdk_mint_rpc::MintMutationGuardError::FailedPrecondition(
"A configuration apply is pending; restart cdk-mintd before making management RPC changes"
.to_owned(),
)),
Ok(false) => Ok(()),
Err(error) => Err(cdk_mint_rpc::MintMutationGuardError::Internal(format!(
"Could not inspect the stored configuration state: {error}"
))),
}
}
}
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
type ConfiguredWalletInfoProvider = Option<cdk_mint_rpc::DynWalletInfoProvider>;
#[cfg(not(all(feature = "management-rpc", feature = "bdk")))]
type ConfiguredWalletInfoProvider = ();
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
#[derive(Clone)]
struct BdkWalletInfoProvider {
bdk: Arc<cdk_bdk::CdkBdk>,
}
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
#[async_trait::async_trait]
impl cdk_mint_rpc::WalletInfoProvider for BdkWalletInfoProvider {
async fn create_deposit_address(
&self,
) -> std::result::Result<String, cdk_mint_rpc::WalletInfoError> {
self.bdk
.create_operator_deposit_address()
.await
.map_err(|err| cdk_mint_rpc::WalletInfoError::new(err.to_string()))
}
async fn get_balance(
&self,
) -> std::result::Result<cdk_mint_rpc::wallet::GetBalanceResponse, cdk_mint_rpc::WalletInfoError>
{
let balance = self.bdk.wallet_balance().await;
Ok(cdk_mint_rpc::wallet::GetBalanceResponse {
confirmed_sat: balance.confirmed_sat,
trusted_pending_sat: balance.trusted_pending_sat,
untrusted_pending_sat: balance.untrusted_pending_sat,
immature_sat: balance.immature_sat,
trusted_spendable_sat: balance.trusted_spendable_sat,
total_sat: balance.total_sat,
network: balance.network,
synced_height: balance.synced_height,
})
}
async fn list_transactions(
&self,
offset: usize,
limit: usize,
) -> std::result::Result<cdk_mint_rpc::WalletTransactionPage, cdk_mint_rpc::WalletInfoError>
{
let page = self
.bdk
.wallet_transactions(offset, limit)
.await
.map_err(|err| cdk_mint_rpc::WalletInfoError::new(err.to_string()))?;
Ok(cdk_mint_rpc::WalletTransactionPage {
transactions: page
.items
.into_iter()
.map(|transaction| cdk_mint_rpc::wallet::WalletTransaction {
txid: transaction.txid,
inputs: transaction
.inputs
.into_iter()
.map(|input| cdk_mint_rpc::wallet::WalletTransactionInput {
txid: input.txid,
vout: input.vout,
amount_sat: input.amount_sat,
address: input.address,
})
.collect(),
outputs: transaction
.outputs
.into_iter()
.map(|output| cdk_mint_rpc::wallet::WalletTransactionOutput {
vout: output.vout,
address: output.address,
amount_sat: output.amount_sat,
quote_id: output.quote_id,
})
.collect(),
received_sat: transaction.received_sat,
sent_sat: transaction.sent_sat,
fee_sat: transaction.fee_sat,
balance_delta_sat: transaction.balance_delta_sat,
confirmation_height: transaction.confirmation_height,
confirmation_time: transaction.confirmation_time,
first_seen: transaction.first_seen,
})
.collect(),
total: page.total,
})
}
async fn list_addresses(
&self,
offset: usize,
limit: usize,
) -> std::result::Result<cdk_mint_rpc::WalletAddressPage, cdk_mint_rpc::WalletInfoError> {
let page = self
.bdk
.wallet_addresses(offset, limit)
.await
.map_err(|err| cdk_mint_rpc::WalletInfoError::new(err.to_string()))?;
Ok(cdk_mint_rpc::WalletAddressPage {
addresses: page
.items
.into_iter()
.map(|address| cdk_mint_rpc::wallet::WalletAddress {
address: address.address,
keychain: match address.keychain {
cdk_bdk::WalletKeychain::External => {
cdk_mint_rpc::wallet::KeychainKind::External.into()
}
cdk_bdk::WalletKeychain::Internal => {
cdk_mint_rpc::wallet::KeychainKind::Internal.into()
}
},
derivation_index: address.derivation_index,
used: address.used,
balance_sat: address.balance_sat,
confirmed_balance_sat: address.confirmed_balance_sat,
})
.collect(),
total: page.total,
})
}
}
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
fn no_wallet_info_provider() -> ConfiguredWalletInfoProvider {
None
}
#[cfg(not(all(feature = "management-rpc", feature = "bdk")))]
fn no_wallet_info_provider() -> ConfiguredWalletInfoProvider {}
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 == "~" {
return home::home_dir();
}
if let Some(remainder) = path.strip_prefix("~/") {
return home::home_dir().map(|home_dir| home_dir.join(remainder));
}
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>,
Arc<dyn KVStoreCompareAndSwap<Err = cdk_database::Error> + Send + Sync>,
)> {
tracing::info!("Initializing database...");
let (localstore, keystore, kv, configuration_store) =
setup_database(settings, work_dir, db_password).await?;
tracing::info!("Database initialized successfully");
Ok((localstore, keystore, kv, configuration_store))
}
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 settings = load_settings_from_sources(work_dir, config_path)?;
validate_settings(&settings)?;
Ok(settings)
}
fn load_settings_from_sources(
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::try_new(Some(config_file_arg.clone()))
.with_context(|| format!("Failed to read config file {}", config_file_arg.display()))?
} else {
tracing::info!("Config file does not exist. Attempting to read env vars");
config::Settings::default()
};
settings.from_env()
}
pub(crate) fn validate_settings(settings: &config::Settings) -> Result<()> {
validate_payment_backends(settings)?;
settings
.validate_backend_pairing()
.map_err(anyhow::Error::msg)?;
validate_listen_config(settings)?;
validate_signing_config(settings)?;
validate_payment_backend_config(settings)?;
validate_onchain_config(settings)?;
validate_database_config(settings)?;
validate_auth_config(settings)?;
validate_management_rpc_config(settings)?;
validate_prometheus_config(settings)?;
Ok(())
}
fn validate_payment_backends(settings: &config::Settings) -> Result<()> {
let has_payment_backend = settings
.payment_backend
.iter()
.any(|backend| backend.backend != PaymentBackendType::None);
let has_onchain_backend = settings
.onchain
.as_ref()
.is_some_and(|onchain| onchain.onchain_backend != config::OnchainBackend::None);
if !has_payment_backend && !has_onchain_backend {
bail!("At least one payment backend must be configured");
}
Ok(())
}
fn validate_database_config(settings: &config::Settings) -> Result<()> {
if settings.database.engine == 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");
}
}
Ok(())
}
fn validate_listen_config(settings: &config::Settings) -> Result<()> {
format!(
"{}:{}",
settings.info.listen_host, settings.info.listen_port
)
.parse::<SocketAddr>()
.map_err(|err| {
anyhow!(
"Invalid mint listen address [info].listen_host/[info].listen_port ({}:{}): {err}",
settings.info.listen_host,
settings.info.listen_port
)
})?;
Ok(())
}
fn validate_signing_config(settings: &config::Settings) -> Result<()> {
const MIN_SEED_BYTES: usize = 32;
if let Some(signatory) = settings.enabled_signatory() {
let has_local_seed = settings
.info
.seed
.as_ref()
.is_some_and(|seed| !seed.is_empty());
let has_local_mnemonic = settings
.info
.mnemonic
.as_ref()
.is_some_and(|mnemonic| !mnemonic.is_empty());
if has_local_seed || has_local_mnemonic {
bail!(
"Remote signatory configuration cannot include [info].seed or [info].mnemonic; \
keep private signing material on the signatory host"
);
}
if signatory.tls_dir.is_none() && !signatory.allow_insecure {
bail!(
"gRPC signatory TLS is not configured. Set [signatory].tls_dir or \
[signatory].allow_insecure = true to connect without TLS"
);
}
return Ok(());
}
let seed = settings.info.seed.as_ref();
let mnemonic = settings
.info
.mnemonic
.as_ref()
.filter(|value| !value.is_empty());
if let Some(seed) = seed {
if seed.is_empty() {
bail!("Seed in [info].seed must not be empty");
}
if seed.len() < MIN_SEED_BYTES {
bail!(
"Seed in [info].seed is too short ({} bytes); require at least {MIN_SEED_BYTES} bytes",
seed.len()
);
}
return Ok(());
}
if let Some(mnemonic) = mnemonic {
Mnemonic::from_str(mnemonic)
.map_err(|err| anyhow!("Invalid mnemonic in [info].mnemonic: {err}"))?;
return Ok(());
}
bail!("No signing source configured. Set [info].mnemonic or [info].seed to an env:/file: secret reference, or enable [signatory]");
}
fn validate_payment_backend_config(settings: &config::Settings) -> Result<()> {
for payment_backend in &settings.payment_backend {
if payment_backend.min_mint > payment_backend.max_mint {
bail!("Payment backend min_mint cannot be greater than max_mint");
}
if payment_backend.min_melt > payment_backend.max_melt {
bail!("Payment backend min_melt cannot be greater than max_melt");
}
match payment_backend.backend {
PaymentBackendType::None => {}
#[cfg(feature = "cln")]
PaymentBackendType::Cln => {
let cln = settings.cln.as_ref().ok_or_else(|| {
anyhow!("CLN backend selected but [cln] config section is missing")
})?;
if cln.rpc_path.as_os_str().is_empty() {
bail!("CLN rpc_path must be set in [cln].rpc_path");
}
}
#[cfg(feature = "lnd")]
PaymentBackendType::Lnd => {
let lnd = settings.lnd.as_ref().ok_or_else(|| {
anyhow!("LND backend selected but [lnd] config section is missing")
})?;
if lnd.address.is_empty() {
bail!("LND address must be set in [lnd].address");
}
if lnd.cert_file.as_os_str().is_empty() {
bail!("LND cert_file must be set in [lnd].cert_file");
}
if lnd.macaroon_file.as_os_str().is_empty() {
bail!("LND macaroon_file must be set in [lnd].macaroon_file");
}
}
#[cfg(feature = "fakewallet")]
PaymentBackendType::FakeWallet => {
let fake_wallet = settings.fake_wallet.as_ref().ok_or_else(|| {
anyhow!(
"Fake wallet backend selected but [fake_wallet] config section is missing"
)
})?;
if fake_wallet.supported_units.is_empty() {
bail!("Fake wallet supported_units must contain at least one unit in [fake_wallet].supported_units");
}
if fake_wallet.min_delay_time > fake_wallet.max_delay_time {
bail!("Fake wallet min_delay_time cannot be greater than max_delay_time");
}
}
#[cfg(feature = "grpc-processor")]
PaymentBackendType::GrpcProcessor => {
let grpc_processor = settings.grpc_processor.as_ref().ok_or_else(|| {
anyhow!(
"gRPC payment processor backend selected but [grpc_processor] config section is missing"
)
})?;
if grpc_processor.supported_units.is_empty() {
bail!("gRPC payment processor supported_units must contain at least one unit in [grpc_processor].supported_units");
}
if grpc_processor.address.is_empty() {
bail!("gRPC payment processor address must be set in [grpc_processor].address");
}
}
#[cfg(feature = "ldk-node")]
PaymentBackendType::LdkNode => {
if settings.ldk_node.is_none() {
bail!("LDK Node backend selected but [ldk_node] config section is missing");
}
}
}
}
Ok(())
}
fn validate_onchain_config(settings: &config::Settings) -> Result<()> {
let Some(onchain) = settings.onchain.as_ref() else {
return Ok(());
};
if onchain.min_mint > onchain.max_mint {
bail!("On-chain min_mint cannot be greater than max_mint");
}
if onchain.min_melt > onchain.max_melt {
bail!("On-chain min_melt cannot be greater than max_melt");
}
match onchain.onchain_backend {
config::OnchainBackend::None => {}
#[cfg(feature = "bdk")]
config::OnchainBackend::Bdk => {
let bdk = settings.bdk.as_ref().ok_or_else(|| {
anyhow!("BDK onchain backend selected but [bdk] config section is missing")
})?;
bdk.validate().map_err(anyhow::Error::msg)?;
}
#[cfg(feature = "fakewallet")]
config::OnchainBackend::FakeWallet => {
if settings.fake_wallet.is_none() {
bail!(
"Fake wallet onchain backend selected but [fake_wallet] config section is missing"
);
}
}
}
Ok(())
}
fn validate_auth_config(settings: &config::Settings) -> Result<()> {
let Some(auth) = settings.auth.as_ref() else {
return Ok(());
};
if auth.openid_discovery.is_empty() {
bail!("Auth openid_discovery must be set in [auth].openid_discovery");
}
if auth.openid_client_id.is_empty() {
bail!("Auth openid_client_id must be set in [auth].openid_client_id");
}
if settings.database.engine == DatabaseEngine::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]")
})?;
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]")
})?;
if auth_pg_config.url.is_empty() {
bail!("Auth database PostgreSQL URL is required. Set [auth_database.postgres].url to an env: or file: secret reference");
}
}
Ok(())
}
fn validate_management_rpc_config(settings: &config::Settings) -> Result<()> {
#[cfg(not(feature = "management-rpc"))]
let _ = settings;
#[cfg(feature = "management-rpc")]
if let Some(rpc_settings) = settings.mint_management_rpc.as_ref() {
if rpc_settings.enabled {
let address = rpc_settings.address.as_deref().unwrap_or("127.0.0.1");
let port = rpc_settings.port.unwrap_or(8086);
format!("{address}:{port}")
.parse::<SocketAddr>()
.map_err(|err| {
anyhow!(
"Invalid mint management RPC address [mint_management_rpc].address/[mint_management_rpc].port ({address}:{port}): {err}"
)
})?;
}
}
Ok(())
}
fn validate_prometheus_config(settings: &config::Settings) -> Result<()> {
#[cfg(not(feature = "prometheus"))]
let _ = settings;
#[cfg(feature = "prometheus")]
if let Some(prometheus_settings) = settings.prometheus.as_ref() {
if prometheus_settings.enabled {
let address = prometheus_settings
.address
.as_deref()
.unwrap_or("127.0.0.1");
let port = prometheus_settings.port.unwrap_or(9000);
format!("{address}:{port}")
.parse::<SocketAddr>()
.map_err(|err| {
anyhow!(
"Invalid Prometheus address [prometheus].address/[prometheus].port ({address}:{port}): {err}"
)
})?;
}
}
Ok(())
}
pub fn load_settings_from_args(work_dir: &Path, args: &CLIArgs) -> Result<config::Settings> {
let mut settings = load_settings_from_sources(work_dir, args.config.clone())?;
if let Some(seed_file) = args.seed_file.as_deref() {
apply_seed_file(&mut settings, seed_file)?;
}
validate_settings(&settings)?;
Ok(settings)
}
pub fn apply_seed_file(settings: &mut config::Settings, seed_file: &Path) -> Result<()> {
let mnemonic = std::fs::read_to_string(seed_file)
.with_context(|| format!("Failed to read seed file {}", seed_file.display()))?;
let mnemonic = mnemonic.trim();
if mnemonic.is_empty() {
bail!("Seed file {} is empty", seed_file.display());
}
Mnemonic::parse(mnemonic)
.with_context(|| format!("Invalid seed phrase in seed file {}", seed_file.display()))?;
settings.info.seed = None;
settings.info.mnemonic = Some(mnemonic.to_owned());
#[cfg(feature = "bdk")]
if settings
.onchain
.as_ref()
.is_some_and(|onchain| onchain.onchain_backend == config::OnchainBackend::Bdk)
{
let mut bdk = settings.bdk.clone().unwrap_or_default();
bdk.mnemonic = Some(mnemonic.to_owned());
settings.bdk = Some(bdk);
}
#[cfg(feature = "ldk-node")]
if settings
.payment_backend
.iter()
.any(|backend| backend.backend == PaymentBackendType::LdkNode)
{
let mut ldk_node = settings.ldk_node.clone().unwrap_or_default();
ldk_node.ldk_node_mnemonic = Some(mnemonic.to_owned());
settings.ldk_node = Some(ldk_node);
}
Ok(())
}
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>,
Arc<dyn KVStoreCompareAndSwap<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 configuration_store: Arc<
dyn KVStoreCompareAndSwap<Err = cdk_database::Error> + Send + Sync,
> = db.clone();
let keystore: Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync> = db;
Ok((localstore, keystore, kv, configuration_store))
}
#[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 db_config = PgConfig::new(
pg_config.url.as_str(),
pg_config.tls_mode.as_deref(),
pg_config.max_connections,
pg_config.connection_timeout_seconds,
);
#[cfg(feature = "postgres")]
let pg_db = Arc::new(MintPgDatabase::new(db_config).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 configuration_store: Arc<
dyn KVStoreCompareAndSwap<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, configuration_store));
#[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_with_wallet_info(
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, ConfiguredWalletInfoProvider)> {
settings
.validate_backend_pairing()
.map_err(anyhow::Error::msg)?;
let mint_builder = configure_basic_info(settings, mint_builder);
#[cfg(feature = "fakewallet")]
if settings
.payment_backend
.iter()
.any(|backend| backend.backend == PaymentBackendType::FakeWallet)
{
if let Some(_onchain) = &settings.onchain {
#[cfg(feature = "bdk")]
if _onchain.onchain_backend == config::OnchainBackend::Bdk {
if let Some(bdk) = &settings.bdk {
if let Some(network) = &bdk.network {
let network = network.to_lowercase();
if network == "mainnet" || network == "bitcoin" {
bail!("Fake wallet cannot be used as a payment backend when On-chain is configured for Mainnet");
}
}
}
}
}
}
let mint_builder = configure_payment_backends(
settings,
mint_builder,
runtime.clone(),
work_dir,
kv_store.clone(),
)
.await?;
let (mint_builder, wallet_info_provider) = configure_onchain_backend_with_wallet_info(
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).await?;
let mint_builder =
mint_builder.with_limits(settings.limits.max_inputs, settings.limits.max_outputs);
if mint_builder
.current_mint_info()
.nuts
.nut04
.methods
.is_empty()
{
bail!("At least one payment backend must be configured");
}
Ok((mint_builder, wallet_info_provider))
}
#[cfg(test)]
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> {
Ok(
configure_mint_builder_with_wallet_info(
settings,
mint_builder,
runtime,
work_dir,
kv_store,
)
.await?
.0,
)
}
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_payment_backends(
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> {
if settings.payment_backend.is_empty() {
tracing::info!("No payment backend configured");
return Ok(mint_builder);
}
#[cfg(feature = "fakewallet")]
let mut configure_fake_wallet_keyset_rotations = false;
for backend_entry in &settings.payment_backend {
let mint_melt_limits = MintMeltLimits {
mint_min: backend_entry.min_mint,
mint_max: backend_entry.max_mint,
melt_min: backend_entry.min_melt,
melt_max: backend_entry.max_melt,
};
tracing::debug!(
"Payment backend: {:?} (unit: {:?})",
backend_entry.backend,
backend_entry.unit
);
match backend_entry.backend {
#[cfg(feature = "cln")]
PaymentBackendType::Cln => {
let cln_settings = settings.cln.clone().ok_or_else(|| {
anyhow!("CLN backend selected but [cln] config section is missing")
})?;
let cln = cln_settings
.setup(
settings,
cdk::nuts::CurrencyUnit::Msat,
None,
work_dir,
_kv_store.clone(),
)
.await?;
#[cfg(feature = "prometheus")]
let cln = MetricsMintPayment::new(cln);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
backend_entry.unit.clone(),
mint_melt_limits,
Arc::new(cln),
)
.await?;
}
#[cfg(feature = "lnd")]
PaymentBackendType::Lnd => {
let lnd_settings = settings.lnd.clone().ok_or_else(|| {
anyhow!("LND backend selected but [lnd] config section is missing")
})?;
let lnd = lnd_settings
.setup(
settings,
cdk::nuts::CurrencyUnit::Msat,
None,
work_dir,
_kv_store.clone(),
)
.await?;
#[cfg(feature = "prometheus")]
let lnd = MetricsMintPayment::new(lnd);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
backend_entry.unit.clone(),
mint_melt_limits,
Arc::new(lnd),
)
.await?;
}
#[cfg(feature = "fakewallet")]
PaymentBackendType::FakeWallet => {
let fake_wallet = settings.fake_wallet.clone().ok_or_else(|| {
anyhow!(
"Fake wallet backend selected but [fake_wallet] config section is missing"
)
})?;
tracing::info!("Using fake wallet: {:?}", fake_wallet);
let fake = fake_wallet
.setup(
settings,
backend_entry.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,
backend_entry.unit.clone(),
mint_melt_limits,
Arc::new(fake),
)
.await?;
configure_fake_wallet_keyset_rotations = true;
}
#[cfg(feature = "grpc-processor")]
PaymentBackendType::GrpcProcessor => {
let grpc_processor = settings.grpc_processor.clone().ok_or_else(|| {
anyhow!(
"gRPC payment processor backend selected but [grpc_processor] config section is missing"
)
})?;
tracing::info!(
"Attempting to start with gRPC payment processor at {}:{}.",
grpc_processor.address,
grpc_processor.port
);
let processor = grpc_processor
.setup(settings, backend_entry.unit.clone(), None, work_dir, None)
.await?;
#[cfg(feature = "prometheus")]
let processor = MetricsMintPayment::new(processor);
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
backend_entry.unit.clone(),
mint_melt_limits,
Arc::new(processor),
)
.await?;
}
#[cfg(feature = "ldk-node")]
PaymentBackendType::LdkNode => {
let ldk_node_settings = settings.ldk_node.clone().ok_or_else(|| {
anyhow!("LDK Node backend selected but [ldk_node] config section is missing")
})?;
tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings);
let ldk_node = ldk_node_settings
.setup(
settings,
backend_entry.unit.clone(),
_runtime.clone(),
work_dir,
_kv_store.clone(),
)
.await?;
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
backend_entry.unit.clone(),
mint_melt_limits,
Arc::new(ldk_node),
)
.await?;
}
PaymentBackendType::None => {
tracing::info!(
"No payment backend configured for unit {:?}",
backend_entry.unit
);
}
};
}
#[cfg(feature = "fakewallet")]
if configure_fake_wallet_keyset_rotations {
let fake_wallet = settings.fake_wallet.as_ref().ok_or_else(|| {
anyhow!("Fake wallet backend selected but [fake_wallet] config section is missing")
})?;
mint_builder = configure_fake_wallet_keyset_rotations_once(mint_builder, fake_wallet);
}
Ok(mint_builder)
}
#[cfg(feature = "fakewallet")]
fn configure_fake_wallet_keyset_rotations_once(
mut mint_builder: MintBuilder,
fake_wallet: &config::FakeWallet,
) -> MintBuilder {
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,
});
}
mint_builder
}
async fn configure_onchain_backend_with_wallet_info(
settings: &config::Settings,
#[cfg_attr(not(feature = "bdk"), allow(unused_mut))] 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, ConfiguredWalletInfoProvider)> {
use config::OnchainBackend;
#[cfg(feature = "bdk")]
use setup::OnchainBackendSetup;
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
let mut wallet_info_provider = no_wallet_info_provider();
if let Some(onchain_settings) = &settings.onchain {
match onchain_settings.onchain_backend {
#[cfg(feature = "bdk")]
OnchainBackend::Bdk => {
let mint_melt_limits = MintMeltLimits {
mint_min: onchain_settings.min_mint,
mint_max: onchain_settings.max_mint,
melt_min: onchain_settings.min_melt,
melt_max: onchain_settings.max_melt,
};
let bdk_settings = settings.bdk.clone().ok_or_else(|| {
anyhow!("BDK onchain backend selected but [bdk] config section is missing")
})?;
let bdk = bdk_settings
.setup(
settings,
cdk::nuts::CurrencyUnit::Sat,
None,
_work_dir,
_kv_store,
)
.await?;
let bdk = Arc::new(bdk);
#[cfg(feature = "management-rpc")]
{
wallet_info_provider = Some(Arc::new(BdkWalletInfoProvider {
bdk: Arc::clone(&bdk),
}));
}
mint_builder = configure_backend_for_unit(
settings,
mint_builder,
cdk::nuts::CurrencyUnit::Sat,
mint_melt_limits,
bdk,
)
.await?;
}
OnchainBackend::None => {}
#[cfg(feature = "fakewallet")]
OnchainBackend::FakeWallet => {
let has_payment_backend = settings
.payment_backend
.iter()
.any(|backend| backend.backend != PaymentBackendType::None);
let has_real_payment_backend = settings.payment_backend.iter().any(|backend| {
!matches!(
backend.backend,
PaymentBackendType::None | PaymentBackendType::FakeWallet
)
});
if !has_payment_backend {
let mint_melt_limits = MintMeltLimits {
mint_min: onchain_settings.min_mint,
mint_max: onchain_settings.max_mint,
melt_min: onchain_settings.min_melt,
melt_max: onchain_settings.max_melt,
};
let fake_wallet = settings
.fake_wallet
.clone()
.ok_or_else(|| anyhow!("Fake wallet config section is missing"))?;
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_methods(
settings,
mint_builder,
unit,
mint_melt_limits,
Arc::new(fake),
vec![PaymentMethod::Known(KnownMethod::Onchain)],
)
.await?;
}
} else if has_real_payment_backend {
bail!(
"onchain_backend = \"fakewallet\" cannot be combined with a real payment backend"
);
}
}
}
}
#[cfg(all(feature = "management-rpc", feature = "bdk"))]
{
Ok((mint_builder, wallet_info_provider))
}
#[cfg(not(all(feature = "management-rpc", feature = "bdk")))]
{
Ok((mint_builder, no_wallet_info_provider()))
}
}
#[cfg(test)]
async fn configure_onchain_backend(
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> {
Ok(configure_onchain_backend_with_wallet_info(
settings,
mint_builder,
runtime,
work_dir,
kv_store,
)
.await?
.0)
}
async fn configure_backend_for_unit(
settings: &config::Settings,
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?;
validate_backend_unit(&unit, &payment_settings.unit)?;
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));
}
if payment_settings.onchain.is_some() {
methods.push(PaymentMethod::Known(KnownMethod::Onchain));
}
for method_name in payment_settings.custom.keys() {
methods.push(PaymentMethod::from(method_name.as_str()));
}
configure_backend_for_methods(
settings,
mint_builder,
unit,
mint_melt_limits,
backend,
methods,
)
.await
}
async fn configure_backend_for_methods(
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>,
methods: Vec<PaymentMethod>,
) -> Result<MintBuilder> {
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 validate_backend_unit(
configured_unit: &cdk::nuts::CurrencyUnit,
backend_unit: &str,
) -> Result<()> {
let backend_unit = cdk::nuts::CurrencyUnit::from_str(backend_unit)
.with_context(|| format!("Payment backend returned invalid unit `{backend_unit}`"))?;
if units_are_compatible(&backend_unit, configured_unit) {
return Ok(());
}
bail!(
"Payment backend reports unit {} but config registers unit {}; only matching units or sat/msat conversions are supported",
backend_unit,
configured_unit
)
}
fn units_are_compatible(
backend_unit: &cdk::nuts::CurrencyUnit,
configured_unit: &cdk::nuts::CurrencyUnit,
) -> bool {
backend_unit == configured_unit
|| matches!(
(backend_unit, configured_unit),
(cdk::nuts::CurrencyUnit::Sat, cdk::nuts::CurrencyUnit::Msat)
| (cdk::nuts::CurrencyUnit::Msat, cdk::nuts::CurrencyUnit::Sat)
)
}
async fn configure_cache(
settings: &config::Settings,
mint_builder: MintBuilder,
payment_methods: &[String],
) -> Result<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 = HttpCache::from_config(settings.info.http_cache.clone()).await?;
Ok(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]")
})?;
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]")
})?;
if auth_pg_config.url.is_empty() {
bail!("Auth database PostgreSQL URL is required and cannot be empty. Set [auth_database.postgres].url to an env: or file: secret reference");
}
let auth_db_config = PgConfig::new(
auth_pg_config.url.as_str(),
auth_pg_config.tls_mode.as_deref(),
auth_pg_config.max_connections,
auth_pg_config.connection_timeout_seconds,
);
Arc::new(MintPgAuthDatabase::new(auth_db_config).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?;
if !unprotected_endpoints.is_empty() {
tx.remove_protected_endpoints(unprotected_endpoints).await?;
}
if !protected_endpoints.is_empty() {
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,
validated_signing_source: Option<&ValidatedSigningSource>,
) -> Result<Mint> {
if let Some(signatory) = settings.enabled_signatory() {
let tls_dir = signatory.tls_dir.clone();
if tls_dir.is_none() {
if !signatory.allow_insecure {
bail!(
"gRPC signatory TLS is not configured. Set [signatory].tls_dir or \
[signatory].allow_insecure = true to connect without TLS"
);
}
tracing::warn!(
"No gRPC signatory TLS directory configured; connecting without TLS because \
allow_insecure is true"
);
}
let remote_signatory = match validated_signing_source
.and_then(|validated| validated.remote_signatory.clone())
{
Some(remote_signatory) => {
tracing::info!(
"Using the remote signatory connection validated during configuration startup"
);
remote_signatory
}
None => {
tracing::info!(
"Connecting to remote signatory at {}:{} with TLS directory {:?}",
signatory.address,
signatory.port,
tls_dir
);
Arc::new(
cdk_signatory::SignatoryRpcClient::new(
&signatory.address,
signatory.port,
tls_dir,
)
.await?,
)
}
};
if let Some(validated) = validated_signing_source {
ensure_signatory_identity(&remote_signatory, validated.expected_pubkey).await?;
}
Ok(mint_builder.build_with_signatory(remote_signatory).await?)
} else if let Some(seed) = settings.info.seed.clone().filter(|seed| !seed.is_empty()) {
if validated_signing_source.is_some_and(|validated| validated.remote_signatory.is_some()) {
bail!("Validated remote signatory provided for local signing configuration");
}
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 ensure_signatory_identity(
signatory: &DynSignatory,
expected_pubkey: cdk::nuts::PublicKey,
) -> Result<()> {
let actual_pubkey = signatory.keysets().await?.pubkey;
if actual_pubkey != expected_pubkey {
return Err(config_service::ConfigurationServiceError::SigningIdentityChange.into());
}
Ok(())
}
async fn reconcile_canonical_configuration(
mint: &Mint,
mut configured_mint_info: cdk::nuts::MintInfo,
configured_quote_ttl: QuoteTTL,
preserve_database_values: bool,
) -> Result<()> {
if !preserve_database_values {
tracing::info!(
"Applying mint info and quote TTL from the database-backed configuration document."
);
if let Ok(stored_mint_info) = mint.mint_info().await {
if configured_mint_info.pubkey.is_none() {
configured_mint_info.pubkey = stored_mint_info.pubkey;
}
}
mint.set_mint_info_and_quote_ttl(configured_mint_info, configured_quote_ttl)
.await?;
return Ok(());
}
if mint.mint_info().await.is_err() {
tracing::info!("Mint info not set on mint, setting.");
mint.set_mint_info_and_quote_ttl(configured_mint_info, configured_quote_ttl)
.await?;
return Ok(());
}
if !mint.quote_ttl_is_persisted().await? {
mint.set_quote_ttl(configured_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!("Preserving RPC-managed mint info from the database.");
Ok(())
}
struct PreparedMintd {
mint: Arc<cdk::mint::Mint>,
#[cfg(feature = "prometheus")]
prometheus: Option<config::Prometheus>,
mint_service: Router,
listen_addr: String,
listen_port: u16,
activation: Option<ConfigurationActivation>,
shutdown_tx: tokio::sync::broadcast::Sender<()>,
#[cfg(feature = "management-rpc")]
rpc_to_start: Option<(cdk_mint_rpc::MintRPCServer, Option<PathBuf>)>,
}
struct RunningMintd {
mint: Arc<cdk::mint::Mint>,
mint_service: Router,
listener: tokio::net::TcpListener,
shutdown_tx: tokio::sync::broadcast::Sender<()>,
#[cfg(feature = "prometheus")]
prometheus_handle: Option<tokio::task::JoinHandle<()>>,
#[cfg(feature = "management-rpc")]
rpc_server: Option<cdk_mint_rpc::MintRPCServer>,
}
impl PreparedMintd {
#[allow(clippy::too_many_arguments)]
async fn prepare(
mint: Arc<cdk::mint::Mint>,
settings: &config::Settings,
_work_dir: &Path,
_wallet_info_provider: ConfiguredWalletInfoProvider,
mint_builder_info: cdk::nuts::MintInfo,
routers: Vec<Router>,
auth_localstore: Option<cdk_common::database::DynMintAuthDatabase>,
activation: Option<ConfigurationActivation>,
) -> Result<Self> {
let listen_addr = settings.info.listen_host.clone();
let listen_port = settings.info.listen_port;
let cache: HttpCache = HttpCache::from_config(settings.info.http_cache.clone()).await?;
#[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_to_start = 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())?
.with_mint_quote_payment_override(
rpc_settings.allow_mint_quote_payment_override,
);
if let Some(activation) = activation.as_ref() {
mint_rpc =
mint_rpc.with_mutation_guard(Arc::new(ConfigurationMutationGuard {
service: activation.service.clone(),
}));
}
#[cfg(feature = "bdk")]
if let Some(provider) = _wallet_info_provider.clone() {
mint_rpc = mint_rpc.with_wallet_info_provider(provider);
}
let tls_dir = rpc_settings.tls_dir.unwrap_or(_work_dir.join("tls"));
let tls_dir = if tls_dir.exists() {
Some(tls_dir)
} else if rpc_settings.allow_insecure {
tracing::warn!(
"TLS directory does not exist: {}. Starting RPC server in INSECURE mode without TLS encryption because allow_insecure is true",
tls_dir.display()
);
None
} else {
bail!(
"Management RPC TLS directory does not exist: {}. Set \
[mint_management_rpc].tls_dir or \
[mint_management_rpc].allow_insecure = true to start without \
TLS",
tls_dir.display()
);
};
rpc_to_start = Some((mint_rpc, tls_dir));
rpc_enabled = true;
}
}
}
let desired_quote_ttl: QuoteTTL = settings.info.quote_ttl.unwrap_or_default();
let preserve_database_values = match activation.as_ref() {
Some(activation) => activation.preserves_database_values(rpc_enabled),
None => rpc_enabled,
};
reconcile_canonical_configuration(
mint.as_ref(),
mint_builder_info,
desired_quote_ttl,
preserve_database_values,
)
.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);
let onchain_method = PaymentMethod::Known(KnownMethod::Onchain);
let onchain_supported =
nut04_methods.contains(&&onchain_method) || nut05_methods.contains(&&onchain_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());
}
if onchain_supported
&& !custom_methods.contains(&PaymentMethod::Known(KnownMethod::Onchain).to_string())
{
custom_methods.push(PaymentMethod::Known(KnownMethod::Onchain).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(DefaultBodyLimit::max(REQUEST_BODY_LIMIT_BYTES))
.layer(
ServiceBuilder::new()
.layer(RequestDecompressionLayer::new())
.layer(CompressionLayer::new()),
)
.layer(TraceLayer::new_for_http());
for router in routers {
mint_service = mint_service.merge(router);
}
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
Ok(Self {
mint,
#[cfg(feature = "prometheus")]
prometheus: settings.prometheus.clone(),
mint_service,
listen_addr,
listen_port,
activation,
shutdown_tx,
#[cfg(feature = "management-rpc")]
rpc_to_start,
})
}
async fn activate(self) -> Result<RunningMintd> {
#[cfg(feature = "management-rpc")]
let rpc_server = {
if let Some((mut mint_rpc, tls_dir)) = self.rpc_to_start {
mint_rpc.start(tls_dir).await?;
Some(mint_rpc)
} else {
None
}
};
#[cfg(feature = "prometheus")]
let prometheus_handle = {
if let Some(prometheus_settings) = &self.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()
.with_context(|| format!("Invalid Prometheus address {addr}:{port}"))?;
let server = cdk_prometheus::PrometheusBuilder::new()
.bind_address(address)
.build_with_cdk_metrics()?;
let mut shutdown_rx = self.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
}
};
self.mint.start().await?;
let socket_addr =
SocketAddr::from_str(&format!("{}:{}", self.listen_addr, self.listen_port))?;
let listener = tokio::net::TcpListener::bind(socket_addr).await?;
tracing::info!("listening on {}", listener.local_addr()?);
if let Some(activation) = &self.activation {
activation.mark_applied().await?;
}
Ok(RunningMintd {
mint: self.mint,
mint_service: self.mint_service,
listener,
shutdown_tx: self.shutdown_tx,
#[cfg(feature = "prometheus")]
prometheus_handle,
#[cfg(feature = "management-rpc")]
rpc_server,
})
}
}
impl RunningMintd {
async fn serve(
self,
shutdown_signal: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<()> {
let shutdown_broadcast_task = {
let shutdown_tx = self.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 = self.shutdown_tx.subscribe();
let axum_shutdown = async move {
let _ = axum_shutdown_rx.recv().await;
};
let axum_result =
axum::serve(self.listener, self.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) = self.prometheus_handle {
if let Err(e) = handle.await {
tracing::warn!("Prometheus server task failed: {}", e);
}
}
self.mint.stop().await?;
#[cfg(feature = "management-rpc")]
{
if let Some(rpc_server) = self.rpc_server {
rpc_server.stop().await?;
}
}
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
async fn start_services_with_shutdown(
mint: Arc<cdk::mint::Mint>,
settings: &config::Settings,
work_dir: &Path,
wallet_info_provider: ConfiguredWalletInfoProvider,
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>,
activation: Option<ConfigurationActivation>,
) -> Result<()> {
let prepared = PreparedMintd::prepare(
mint,
settings,
work_dir,
wallet_info_provider,
mint_builder_info,
routers,
auth_localstore,
activation,
)
.await?;
let running = prepared.activate().await?;
running.serve(shutdown_signal).await
}
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, _configuration_store) =
initial_setup(work_dir, settings, db_password.clone()).await?;
run_mintd_with_database_and_shutdown(
work_dir,
settings,
localstore,
keystore,
kv,
shutdown_signal,
db_password,
runtime,
routers,
None,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_mintd_with_database_and_shutdown(
work_dir: &Path,
settings: &config::Settings,
localstore: DynMintDatabase,
keystore: Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
kv: Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync>,
shutdown_signal: impl std::future::Future<Output = ()> + Send + 'static,
db_password: Option<String>,
runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
routers: Vec<Router>,
activation: Option<ConfigurationActivation>,
validated_signing_source: Option<ValidatedSigningSource>,
) -> Result<()> {
let mint_builder = MintBuilder::new(localstore);
let maybe_mint_builder = {
#[cfg(feature = "management-rpc")]
{
if activation
.as_ref()
.is_some_and(ConfigurationActivation::forces_configuration)
{
mint_builder
} else 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, wallet_info_provider) = configure_mint_builder_with_wallet_info(
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,
validated_signing_source.as_ref(),
)
.await?;
tracing::debug!("Mint built from builder.");
let mint = Arc::new(mint);
start_services_with_shutdown(
mint.clone(),
settings,
work_dir,
wallet_info_provider,
config_mint_info,
shutdown_signal,
routers,
auth_localstore,
activation,
)
.await
}
fn load_database_bootstrap_settings() -> Result<config::Settings> {
let mut settings = config::Settings::default();
if let Ok(database) = env::var(env_vars::DATABASE_ENV_VAR) {
settings.database.engine =
DatabaseEngine::from_str(&database).map_err(anyhow::Error::msg)?;
}
if settings.database.engine == DatabaseEngine::Postgres {
settings.database.postgres = Some(config::PostgresConfig::default().from_env());
} else {
settings.database.postgres = None;
}
validate_database_config(&settings)?;
Ok(settings)
}
fn configuration_service(
store: Arc<dyn KVStoreCompareAndSwap<Err = cdk_database::Error> + Send + Sync>,
settings: &config::Settings,
) -> config_service::ConfigurationService {
config_service::ConfigurationService::new(
config_store::ConfigRepository::new(store),
settings.database.clone(),
)
}
pub async fn validate_configuration_document(document: &str) -> Result<()> {
config_service::ConfigurationService::validate_import(document).await?;
Ok(())
}
pub async fn initialize_configuration(
work_dir: &Path,
document: &str,
db_password: Option<String>,
) -> Result<()> {
let bootstrap = load_database_bootstrap_settings()?;
let (localstore, _keystore, _kv, configuration_store) =
initial_setup(work_dir, &bootstrap, db_password).await?;
let mut mint_builder = MintBuilder::new(localstore);
mint_builder.init_from_db_if_present().await?;
let database_pubkey = mint_builder.current_mint_info().pubkey;
configuration_service(configuration_store, &bootstrap)
.initialize(document, database_pubkey)
.await?;
Ok(())
}
pub async fn apply_configuration(
work_dir: &Path,
document: &str,
validate_only: bool,
db_password: Option<String>,
) -> Result<ApplyOutcome> {
let bootstrap = load_database_bootstrap_settings()?;
let (_localstore, _keystore, _kv, configuration_store) =
initial_setup(work_dir, &bootstrap, db_password).await?;
Ok(configuration_service(configuration_store, &bootstrap)
.apply(document, validate_only)
.await?)
}
pub async fn rollback_configuration(
work_dir: &Path,
db_password: Option<String>,
) -> Result<RollbackOutcome> {
let bootstrap = load_database_bootstrap_settings()?;
let (_localstore, _keystore, _kv, configuration_store) =
initial_setup(work_dir, &bootstrap, db_password).await?;
Ok(configuration_service(configuration_store, &bootstrap)
.rollback()
.await?)
}
pub async fn stored_configuration_document(
work_dir: &Path,
db_password: Option<String>,
) -> Result<String> {
let bootstrap = load_database_bootstrap_settings()?;
let (_localstore, _keystore, _kv, configuration_store) =
initial_setup(work_dir, &bootstrap, db_password).await?;
Ok(configuration_service(configuration_store, &bootstrap)
.document()
.await?)
}
pub async fn run_mintd_from_database(
work_dir: &Path,
db_password: Option<String>,
enable_logging: bool,
runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
routers: Vec<Router>,
) -> Result<()> {
let bootstrap = load_database_bootstrap_settings()?;
let (localstore, keystore, kv, configuration_store) =
initial_setup(work_dir, &bootstrap, db_password.clone()).await?;
let service = configuration_service(configuration_store, &bootstrap);
let startup = service.startup().await?;
let validated_signing_source = Some(ValidatedSigningSource {
expected_pubkey: startup.signing_identity.pubkey,
remote_signatory: startup
.remote_signatory
.map(|signatory| -> DynSignatory { signatory }),
});
let activation = Some(ConfigurationActivation::new(
service,
startup.state,
startup.revision,
));
let settings = startup.resolved.settings;
let guard = if enable_logging {
setup_tracing(work_dir, &settings.info.logging)?
} else {
None
};
let result = run_mintd_with_database_and_shutdown(
work_dir,
&settings,
localstore,
keystore,
kv,
shutdown_signal(),
db_password,
runtime,
routers,
activation,
validated_signing_source,
)
.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
}
#[cfg(test)]
mod tests {
use std::fs;
use cdk::nuts::{CurrencyUnit, MintMethodSettings, PaymentMethod};
use super::*;
const TEST_MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
fn temp_seed_file(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("cdk_mintd_{name}_{}", std::process::id()))
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn validated_remote_signatory_identity_is_checked_at_mint_build_boundary() {
use cdk_signatory::db_signatory::DbSignatory;
use cdk_signatory::signatory::Signatory;
use cdk_sqlite::mint::memory;
let expected_store = Arc::new(memory::empty().await.expect("expected signatory database"));
let expected_signatory =
DbSignatory::new(expected_store, &[7; 32], HashMap::new(), Default::default())
.await
.expect("expected signatory");
let expected_pubkey = expected_signatory
.keysets()
.await
.expect("expected keysets")
.pubkey;
let actual_store = Arc::new(memory::empty().await.expect("actual signatory database"));
let actual_signatory: DynSignatory = Arc::new(
DbSignatory::new(actual_store, &[9; 32], HashMap::new(), Default::default())
.await
.expect("actual signatory"),
);
let actual_pubkey = actual_signatory
.keysets()
.await
.expect("actual keysets")
.pubkey;
ensure_signatory_identity(&actual_signatory, actual_pubkey)
.await
.expect("matching identity");
let error = ensure_signatory_identity(&actual_signatory, expected_pubkey)
.await
.expect_err("changed identity should be rejected");
assert!(
error
.to_string()
.contains("signing identity does not match this mint database"),
"unexpected error: {error}"
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn unapplied_configuration_refreshes_canonical_values_once() {
use cdk_sqlite::mint::memory;
let database = Arc::new(memory::empty().await.expect("in-memory database"));
let mut builder = MintBuilder::new(database.clone());
builder
.configure_unit(CurrencyUnit::Sat, Default::default())
.expect("configure unit");
let mint = builder
.build_with_seed(database.clone(), &[7; 32])
.await
.expect("build mint");
let stored_info = MintBuilder::new(database.clone())
.with_name("stored".to_owned())
.current_mint_info();
mint.set_mint_info(stored_info)
.await
.expect("set stored mint info");
mint.set_quote_ttl(QuoteTTL::new(10, 20))
.await
.expect("set stored quote ttl");
let imported_info = MintBuilder::new(database.clone())
.with_name("imported".to_owned())
.current_mint_info();
reconcile_canonical_configuration(&mint, imported_info, QuoteTTL::new(30, 40), false)
.await
.expect("apply imported canonical values");
assert_eq!(
mint.mint_info().await.expect("mint info").name.as_deref(),
Some("imported")
);
assert_eq!(
mint.quote_ttl().await.expect("quote ttl"),
QuoteTTL::new(30, 40)
);
let mut rpc_info = mint.mint_info().await.expect("mint info");
rpc_info.name = Some("rpc-managed".to_owned());
mint.set_mint_info(rpc_info)
.await
.expect("set RPC-managed mint info");
mint.set_quote_ttl(QuoteTTL::new(50, 60))
.await
.expect("set RPC-managed quote ttl");
let later_document_info = MintBuilder::new(database)
.with_name("document".to_owned())
.current_mint_info();
reconcile_canonical_configuration(&mint, later_document_info, QuoteTTL::new(70, 80), true)
.await
.expect("preserve RPC-managed canonical values");
assert_eq!(
mint.mint_info().await.expect("mint info").name.as_deref(),
Some("rpc-managed")
);
assert_eq!(
mint.quote_ttl().await.expect("quote ttl"),
QuoteTTL::new(50, 60)
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn preserve_mode_seeds_empty_mint_and_missing_quote_ttl() {
use cdk_sqlite::mint::memory;
let database = Arc::new(memory::empty().await.expect("in-memory database"));
let mut builder = MintBuilder::new(database.clone());
builder
.configure_unit(CurrencyUnit::Sat, Default::default())
.expect("configure unit");
let mint = builder
.build_with_seed(database.clone(), &[9; 32])
.await
.expect("build mint");
let seeded_info = MintBuilder::new(database.clone())
.with_name("seeded".to_owned())
.current_mint_info();
if mint.mint_info().await.is_ok() {
let database = Arc::new(memory::empty().await.expect("second database"));
let mut builder = MintBuilder::new(database.clone());
builder
.configure_unit(CurrencyUnit::Sat, Default::default())
.expect("configure unit");
let mint = builder
.build_with_seed(database.clone(), &[11; 32])
.await
.expect("build mint");
assert!(!mint
.quote_ttl_is_persisted()
.await
.expect("quote ttl persistence probe"));
let info = MintBuilder::new(database)
.with_name("preserve-ttl".to_owned())
.current_mint_info();
reconcile_canonical_configuration(&mint, info, QuoteTTL::new(1, 2), true)
.await
.expect("preserve with missing ttl");
assert!(mint
.quote_ttl_is_persisted()
.await
.expect("quote ttl should now be persisted"));
assert_eq!(
mint.quote_ttl().await.expect("quote ttl"),
QuoteTTL::new(1, 2)
);
} else {
reconcile_canonical_configuration(&mint, seeded_info, QuoteTTL::new(3, 4), true)
.await
.expect("seed mint info when missing");
assert_eq!(
mint.mint_info().await.expect("mint info").name.as_deref(),
Some("seeded")
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn unapplied_configuration_preserves_existing_mint_pubkey() {
use cdk::nuts::PublicKey;
use cdk_sqlite::mint::memory;
let database = Arc::new(memory::empty().await.expect("in-memory database"));
let mut builder = MintBuilder::new(database.clone());
builder
.configure_unit(CurrencyUnit::Sat, Default::default())
.expect("configure unit");
let mint = builder
.build_with_seed(database.clone(), &[13; 32])
.await
.expect("build mint");
let pubkey = PublicKey::from_hex(
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619",
)
.expect("static pubkey");
let mut stored = MintBuilder::new(database.clone())
.with_name("stored".to_owned())
.current_mint_info();
stored.pubkey = Some(pubkey);
mint.set_mint_info(stored)
.await
.expect("set stored mint info");
let mut imported = MintBuilder::new(database)
.with_name("imported".to_owned())
.current_mint_info();
imported.pubkey = None;
reconcile_canonical_configuration(&mint, imported, QuoteTTL::new(7, 8), false)
.await
.expect("apply imported values");
assert_eq!(
mint.mint_info().await.expect("mint info").pubkey,
Some(pubkey)
);
assert_eq!(
mint.mint_info().await.expect("mint info").name.as_deref(),
Some("imported")
);
}
#[cfg(all(feature = "sqlite", feature = "fakewallet"))]
#[tokio::test]
async fn database_configuration_public_api_round_trip() {
let work_dir = crate::test_utils::unique_temp_path("cdk_mintd_public_config_api");
fs::create_dir_all(&work_dir).expect("create work dir");
let secret_path = work_dir.join("mnemonic.secret");
fs::write(&secret_path, TEST_MNEMONIC).expect("write mnemonic secret");
#[cfg(feature = "sqlcipher")]
let password = Some("test-password".to_string());
#[cfg(not(feature = "sqlcipher"))]
let password: Option<String> = None;
let first = format!(
r#"
[info]
mnemonic = "file:{}"
[mint_info]
name = "first-public"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
[database]
engine = "sqlite"
"#,
secret_path.display()
);
let second = first.replace("first-public", "second-public");
validate_configuration_document(&first)
.await
.expect("validate first document");
initialize_configuration(&work_dir, &first, password.clone())
.await
.expect("initialize configuration");
assert_eq!(
stored_configuration_document(&work_dir, password.clone())
.await
.expect("read stored document"),
first
);
let validate_only = apply_configuration(&work_dir, &second, true, password.clone())
.await
.expect("validate-only apply");
assert!(!validate_only.restart_required);
assert_eq!(
stored_configuration_document(&work_dir, password.clone())
.await
.expect("document unchanged"),
first
);
let applied = apply_configuration(&work_dir, &second, false, password.clone())
.await
.expect("apply replacement");
assert!(applied.restart_required);
assert_eq!(
stored_configuration_document(&work_dir, password)
.await
.expect("replacement stored"),
second
);
let bootstrap = load_database_bootstrap_settings().expect("bootstrap settings");
assert_eq!(bootstrap.database.engine, DatabaseEngine::Sqlite);
assert!(bootstrap.database.postgres.is_none());
let _ = fs::remove_dir_all(&work_dir);
}
#[test]
fn load_database_bootstrap_settings_defaults_to_sqlite() {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
std::env::remove_var(env_vars::DATABASE_ENV_VAR);
let settings = load_database_bootstrap_settings().expect("default bootstrap");
assert_eq!(settings.database.engine, DatabaseEngine::Sqlite);
assert!(settings.database.postgres.is_none());
clear_mintd_env();
}
#[test]
fn apply_seed_file_sets_mint_mnemonic_from_trimmed_file_contents() {
let seed_file = temp_seed_file("seed_file_sets_seed");
fs::write(&seed_file, format!(" {TEST_MNEMONIC}\n")).expect("seed file should be written");
let mut settings = config::Settings {
info: config::Info {
seed: Some("raw seed from config".to_string()),
mnemonic: Some("mnemonic from config".to_string()),
..Default::default()
},
signatory: Some(config::Signatory {
enabled: true,
address: "127.0.0.1".to_string(),
port: 15060,
tls_dir: Some("/tmp/certs".into()),
allow_insecure: false,
}),
..Default::default()
};
apply_seed_file(&mut settings, &seed_file).expect("seed file should be applied");
assert_eq!(settings.info.seed, None);
assert_eq!(settings.info.mnemonic, Some(TEST_MNEMONIC.to_string()));
assert_eq!(
settings
.signatory
.as_ref()
.map(|signatory| signatory.address.clone()),
Some("127.0.0.1".to_string())
);
assert_eq!(
settings.signatory.as_ref().map(|signatory| signatory.port),
Some(15060)
);
assert_eq!(
settings
.signatory
.as_ref()
.and_then(|signatory| signatory.tls_dir.clone()),
Some("/tmp/certs".into())
);
let _ = fs::remove_file(&seed_file);
}
#[cfg(feature = "bdk")]
#[test]
fn apply_seed_file_sets_active_bdk_mnemonic() {
use crate::config::{Bdk, Onchain, OnchainBackend};
let seed_file = temp_seed_file("seed_file_sets_bdk_seed");
fs::write(&seed_file, TEST_MNEMONIC).expect("seed file should be written");
let mut settings = config::Settings {
onchain: Some(Onchain {
onchain_backend: OnchainBackend::Bdk,
..Default::default()
}),
bdk: Some(Bdk {
mnemonic: Some("old bdk mnemonic".to_string()),
..Default::default()
}),
..Default::default()
};
apply_seed_file(&mut settings, &seed_file).expect("seed file should be applied");
assert_eq!(
settings
.bdk
.expect("bdk settings should be present")
.mnemonic,
Some(TEST_MNEMONIC.to_string())
);
let _ = fs::remove_file(&seed_file);
}
#[cfg(feature = "ldk-node")]
#[test]
fn apply_seed_file_sets_active_ldk_node_mnemonic() {
use crate::config::{LdkNode, PaymentBackend, PaymentBackendType};
let seed_file = temp_seed_file("seed_file_sets_ldk_seed");
fs::write(&seed_file, TEST_MNEMONIC).expect("seed file should be written");
let mut settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::LdkNode,
..Default::default()
}],
ldk_node: Some(LdkNode {
ldk_node_mnemonic: Some("old ldk mnemonic".to_string()),
..Default::default()
}),
..Default::default()
};
apply_seed_file(&mut settings, &seed_file).expect("seed file should be applied");
assert_eq!(
settings
.ldk_node
.expect("ldk node settings should be present")
.ldk_node_mnemonic,
Some(TEST_MNEMONIC.to_string())
);
let _ = fs::remove_file(&seed_file);
}
#[test]
fn apply_seed_file_rejects_empty_seed_file() {
let seed_file = temp_seed_file("empty_seed_file");
fs::write(&seed_file, "\n\t ").expect("seed file should be written");
let mut settings = config::Settings::default();
let err = apply_seed_file(&mut settings, &seed_file)
.expect_err("empty seed file should be rejected");
assert!(err.to_string().contains("is empty"));
assert_eq!(settings.info.seed, None);
let _ = fs::remove_file(&seed_file);
}
#[test]
fn apply_seed_file_rejects_invalid_seed_phrase() {
let seed_file = temp_seed_file("invalid_seed_file");
fs::write(&seed_file, "not a valid seed phrase").expect("seed file should be written");
let mut settings = config::Settings::default();
let err = apply_seed_file(&mut settings, &seed_file)
.expect_err("invalid seed phrase should be rejected");
assert!(err.to_string().contains("Invalid seed phrase"));
assert_eq!(settings.info.mnemonic, None);
let _ = fs::remove_file(&seed_file);
}
#[cfg(feature = "fakewallet")]
#[test]
fn load_settings_from_args_applies_seed_file_before_validation() {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
let temp_dir = crate::test_utils::unique_temp_path("seed_file_only_signing");
fs::create_dir_all(&temp_dir).expect("temp directory should be created");
let config_path = temp_dir.join("config.toml");
fs::write(
&config_path,
r#"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
)
.expect("config file should be written");
let seed_file = temp_dir.join("seed.txt");
fs::write(&seed_file, TEST_MNEMONIC).expect("seed file should be written");
let args = CLIArgs {
work_dir: None,
#[cfg(feature = "sqlcipher")]
password: Some("test-password".to_string()),
config: Some(config_path),
seed_file: Some(seed_file),
enable_logging: false,
command: None,
};
let settings = load_settings_from_args(&temp_dir, &args)
.expect("seed-file-only signing should pass validation");
assert_eq!(settings.info.mnemonic.as_deref(), Some(TEST_MNEMONIC));
let _ = fs::remove_dir_all(&temp_dir);
clear_mintd_env();
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn fakewallet_dispatcher_uses_payment_backend_entry_unit() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{FakeWallet, PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::FakeWallet,
unit: CurrencyUnit::Eur,
..Default::default()
}],
fake_wallet: Some(FakeWallet::default()),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder =
configure_payment_backends(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect("dispatcher should succeed");
let mint_info = builder.current_mint_info();
let units: Vec<_> = mint_info
.nuts
.nut04
.methods
.iter()
.map(|m| m.unit.clone())
.collect();
assert!(
units.contains(&CurrencyUnit::Eur),
"expected Eur, got {units:?}"
);
assert!(
!units.contains(&CurrencyUnit::Sat),
"Sat would only appear if supported_units leaked through; got {units:?}"
);
}
#[test]
fn backend_unit_validation_allows_matching_units() {
validate_backend_unit(&CurrencyUnit::Eur, "EUR").expect("matching units should pass");
}
#[test]
fn backend_unit_validation_allows_sat_msat_pair() {
validate_backend_unit(&CurrencyUnit::Sat, "MSAT")
.expect("sat/msat compatible units should pass");
validate_backend_unit(&CurrencyUnit::Msat, "SAT")
.expect("msat/sat compatible units should pass");
}
#[test]
fn backend_unit_validation_rejects_unsupported_conversion() {
let err = validate_backend_unit(&CurrencyUnit::Eur, "SAT")
.expect_err("sat backend should not advertise eur");
assert!(
err.to_string().contains("only matching units"),
"error should explain the supported conversions: {err}"
);
}
#[cfg(feature = "cln")]
#[test]
fn expand_path_expands_bare_tilde_without_panic() {
let expanded = expand_path("~");
assert_eq!(expanded, home::home_dir());
}
#[cfg(feature = "cln")]
#[test]
fn expand_path_keeps_named_tilde_paths_literal() {
let expanded = expand_path("~foo").expect("path should be returned");
assert_eq!(expanded, PathBuf::from("~foo"));
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn duplicate_payment_backend_unit_method_pair_is_rejected() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{FakeWallet, PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![
PaymentBackend {
backend: PaymentBackendType::FakeWallet,
unit: CurrencyUnit::Sat,
..Default::default()
},
PaymentBackend {
backend: PaymentBackendType::FakeWallet,
unit: CurrencyUnit::Sat,
..Default::default()
},
],
fake_wallet: Some(FakeWallet::default()),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let err = configure_payment_backends(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect_err("duplicate unit/method pair should be rejected");
assert!(err.to_string().contains("Duplicate payment processor"));
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn empty_payment_backend_vec_returns_unchanged_builder() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
let settings = config::Settings {
payment_backend: vec![],
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder =
configure_payment_backends(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect("empty payment_backend should succeed");
let mint_info = builder.current_mint_info();
assert!(
mint_info.nuts.nut04.methods.is_empty(),
"no backends should be registered"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn payment_backend_none_logs_and_continues() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::None,
unit: CurrencyUnit::Sat,
..Default::default()
}],
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder =
configure_payment_backends(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect("PaymentBackendType::None should succeed");
let mint_info = builder.current_mint_info();
assert!(
mint_info.nuts.nut04.methods.is_empty(),
"PaymentBackendType::None should not register any methods"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn onchain_backend_none_returns_unchanged() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{Onchain, OnchainBackend};
let settings = config::Settings {
onchain: Some(Onchain {
onchain_backend: OnchainBackend::None,
..Default::default()
}),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder =
configure_onchain_backend(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect("OnchainBackend::None should succeed");
let mint_info = builder.current_mint_info();
assert!(
mint_info.nuts.nut04.methods.is_empty(),
"OnchainBackend::None should not register any methods"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn fakewallet_onchain_no_payment_backend_configures_onchain_methods() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{
FakeWallet, Onchain, OnchainBackend, PaymentBackend, PaymentBackendType,
};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::None,
..Default::default()
}],
onchain: Some(Onchain {
onchain_backend: OnchainBackend::FakeWallet,
..Default::default()
}),
fake_wallet: Some(FakeWallet::default()),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder =
configure_onchain_backend(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect("fakewallet onchain should succeed");
let mint_info = builder.current_mint_info();
let methods: Vec<_> = mint_info
.nuts
.nut04
.methods
.iter()
.map(|m| m.method.clone())
.collect();
assert!(
methods.contains(&PaymentMethod::Known(KnownMethod::Onchain)),
"expected onchain method, got {methods:?}"
);
}
#[cfg(all(feature = "fakewallet", feature = "cln", feature = "sqlite"))]
#[tokio::test]
async fn fakewallet_onchain_with_real_payment_backend_bails() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{Onchain, OnchainBackend, PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::Cln,
unit: CurrencyUnit::Sat,
..Default::default()
}],
onchain: Some(Onchain {
onchain_backend: OnchainBackend::FakeWallet,
..Default::default()
}),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let err = configure_onchain_backend(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect_err("fakewallet onchain with real payment backend should bail");
assert!(
err.to_string().contains("fakewallet"),
"error should mention fakewallet: {err}"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn configure_mint_builder_no_backends_bails() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::None,
..Default::default()
}],
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let err = configure_mint_builder(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect_err("no payment backends should bail");
assert!(
err.to_string().contains("At least one payment backend"),
"error should mention missing backends: {err}"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite", feature = "bdk"))]
#[tokio::test]
async fn configure_mint_builder_fake_wallet_with_bdk_onchain_bails() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{
Bdk, FakeWallet, Onchain, OnchainBackend, PaymentBackend, PaymentBackendType,
};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::FakeWallet,
..Default::default()
}],
onchain: Some(Onchain {
onchain_backend: OnchainBackend::Bdk,
..Default::default()
}),
fake_wallet: Some(FakeWallet::default()),
bdk: Some(Bdk {
network: Some("mainnet".to_string()),
..Default::default()
}),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let err = configure_mint_builder(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect_err("fake wallet with BDK onchain should bail");
assert!(
err.to_string().contains("fakewallet") && err.to_string().contains("bdk"),
"error should mention backend pairing validation: {err}"
);
}
#[cfg(all(feature = "management-rpc", feature = "bdk", feature = "sqlite"))]
#[tokio::test]
async fn bdk_onchain_exposes_wallet_info_provider() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{Bdk, Onchain, OnchainBackend};
let work_dir = test_utils::unique_temp_path("cdk_mintd_wallet_info_provider");
let settings = config::Settings {
onchain: Some(Onchain {
onchain_backend: OnchainBackend::Bdk,
..Default::default()
}),
bdk: Some(Bdk {
network: Some("regtest".to_string()),
chain_source_type: Some("esplora".to_string()),
esplora_url: Some("http://127.0.0.1:1".to_string()),
mnemonic: Some(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
.to_string(),
),
..Default::default()
}),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.expect("in-memory database"));
let builder = MintBuilder::new(localstore.clone());
let (builder, provider) = configure_onchain_backend_with_wallet_info(
&settings,
builder,
None,
&work_dir,
Some(localstore),
)
.await
.expect("configure BDK backend");
assert!(builder
.current_mint_info()
.nuts
.nut04
.methods
.iter()
.any(|method| method.method == PaymentMethod::Known(KnownMethod::Onchain)));
let provider = provider.expect("wallet info provider");
let first_address = provider
.create_deposit_address()
.await
.expect("create first operator deposit address");
let second_address = provider
.create_deposit_address()
.await
.expect("create second operator deposit address");
assert_ne!(first_address, second_address);
let addresses = provider
.list_addresses(0, 20)
.await
.expect("list wallet addresses");
assert_eq!(addresses.total, 2);
assert!(addresses
.addresses
.iter()
.any(|address| address.address == first_address));
assert!(addresses
.addresses
.iter()
.any(|address| address.address == second_address));
let balance = provider.get_balance().await.expect("get wallet balance");
assert_eq!(balance.network, "regtest");
assert_eq!(balance.total_sat, 0);
drop(builder);
let _ = std::fs::remove_dir_all(work_dir);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn configure_backend_for_methods_registers_websockets_and_fee() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{FakeWallet, PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::FakeWallet,
unit: CurrencyUnit::Sat,
..Default::default()
}],
fake_wallet: Some(FakeWallet::default()),
info: config::Info {
input_fee_ppk: Some(100),
..Default::default()
},
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let fake_wallet = settings.fake_wallet.clone().expect("fake wallet config");
let fake = fake_wallet
.setup(
&settings,
CurrencyUnit::Sat,
None,
&std::env::temp_dir(),
None,
)
.await
.expect("fake wallet setup");
let mint_melt_limits = cdk::mint::MintMeltLimits {
mint_min: 1.into(),
mint_max: 500_000.into(),
melt_min: 1.into(),
melt_max: 500_000.into(),
};
let builder = configure_backend_for_methods(
&settings,
builder,
CurrencyUnit::Sat,
mint_melt_limits,
Arc::new(fake),
vec![PaymentMethod::Known(KnownMethod::Bolt11)],
)
.await
.expect("configure_backend_for_methods should succeed");
let mint_info = builder.current_mint_info();
assert!(
!mint_info.nuts.nut04.methods.is_empty(),
"bolt11 method should be registered"
);
assert!(
!mint_info.nuts.nut17.supported.is_empty(),
"websocket support should be configured"
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn fakewallet_onchain_with_fake_payment_backend_does_not_duplicate() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{
FakeWallet, Onchain, OnchainBackend, PaymentBackend, PaymentBackendType,
};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::FakeWallet,
unit: CurrencyUnit::Sat,
..Default::default()
}],
onchain: Some(Onchain {
onchain_backend: OnchainBackend::FakeWallet,
..Default::default()
}),
fake_wallet: Some(FakeWallet::default()),
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let builder = configure_onchain_backend(
&settings,
builder,
None,
&std::env::temp_dir(),
None,
)
.await
.expect("fakewallet onchain with fake payment backend should succeed without duplicating");
let mint_info = builder.current_mint_info();
assert!(
mint_info.nuts.nut04.methods.is_empty(),
"when has_payment_backend is true and no real payment backend, fakewallet onchain should skip; got {:?}",
mint_info.nuts.nut04.methods
);
}
#[cfg(all(feature = "fakewallet", feature = "sqlite"))]
#[tokio::test]
async fn fakewallet_onchain_missing_fake_wallet_config_bails() {
use cdk::mint::MintBuilder;
use cdk_sqlite::mint::memory;
use crate::config::{Onchain, OnchainBackend, PaymentBackend, PaymentBackendType};
let settings = config::Settings {
payment_backend: vec![PaymentBackend {
backend: PaymentBackendType::None,
..Default::default()
}],
onchain: Some(Onchain {
onchain_backend: OnchainBackend::FakeWallet,
..Default::default()
}),
fake_wallet: None,
..Default::default()
};
let localstore = Arc::new(memory::empty().await.unwrap());
let builder = MintBuilder::new(localstore);
let err = configure_onchain_backend(&settings, builder, None, &std::env::temp_dir(), None)
.await
.expect_err("missing fake_wallet config should bail");
assert!(
err.to_string().contains("Fake wallet config"),
"error should mention missing config: {err}"
);
}
#[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,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt12),
unit: CurrencyUnit::Sat,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Known(KnownMethod::Bolt11),
unit: CurrencyUnit::Msat,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Custom("paypal".to_string()),
unit: CurrencyUnit::Usd,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
},
MintMethodSettings {
method: PaymentMethod::Custom("paypal".to_string()),
unit: CurrencyUnit::Eur,
method_name: None,
min_amount: None,
max_amount: None,
options: None,
},
];
let methods = extract_supported_payment_methods(&mint_info);
assert_eq!(methods, vec!["bolt11", "bolt12", "paypal"]);
}
fn clear_mintd_env() {
for var in [
"CDK_MINTD_DATABASE",
"CDK_MINTD_DATABASE_URL",
"CDK_MINTD_POSTGRES_URL",
"CDK_MINTD_POSTGRES_TLS_MODE",
"CDK_MINTD_POSTGRES_MAX_CONNECTIONS",
"CDK_MINTD_POSTGRES_CONNECTION_TIMEOUT_SECONDS",
"CDK_MINTD_SEED",
"CDK_MINTD_MNEMONIC",
"CDK_MINTD_SIGNATORY_ENABLED",
"CDK_MINTD_SIGNATORY_ADDRESS",
"CDK_MINTD_SIGNATORY_PORT",
"CDK_MINTD_SIGNATORY_TLS_DIR",
"CDK_MINTD_SIGNATORY_ALLOW_INSECURE",
"CDK_MINTD_LISTEN_HOST",
"CDK_MINTD_LISTEN_PORT",
"CDK_MINTD_PAYMENT_BACKEND",
"CDK_MINTD_PAYMENT_BACKEND_MIN_MINT",
"CDK_MINTD_PAYMENT_BACKEND_MAX_MINT",
"CDK_MINTD_PAYMENT_BACKEND_MIN_MELT",
"CDK_MINTD_PAYMENT_BACKEND_MAX_MELT",
"CDK_MINTD_AUTH_ENABLED",
"CDK_MINTD_AUTH_OPENID_DISCOVERY",
"CDK_MINTD_AUTH_OPENID_CLIENT_ID",
"CDK_MINTD_AUTH_MINT_MAX_BAT",
"CDK_MINTD_AUTH_MINT",
"CDK_MINTD_AUTH_GET_MINT_QUOTE",
"CDK_MINTD_AUTH_CHECK_MINT_QUOTE",
"CDK_MINTD_AUTH_MELT",
"CDK_MINTD_AUTH_GET_MELT_QUOTE",
"CDK_MINTD_AUTH_CHECK_MELT_QUOTE",
"CDK_MINTD_AUTH_SWAP",
"CDK_MINTD_AUTH_RESTORE",
"CDK_MINTD_AUTH_CHECK_PROOF_STATE",
"CDK_MINTD_AUTH_WEBSOCKET",
"CDK_MINTD_AUTH_POSTGRES_URL",
"CDK_MINTD_AUTH_POSTGRES_TLS_MODE",
"CDK_MINTD_AUTH_POSTGRES_MAX_CONNECTIONS",
"CDK_MINTD_AUTH_POSTGRES_CONNECTION_TIMEOUT_SECONDS",
"CDK_MINTD_CLN_RPC_PATH",
"CDK_MINTD_CLN_FEE_PERCENT",
"CDK_MINTD_LND_ADDRESS",
"CDK_MINTD_LND_CERT_FILE",
"CDK_MINTD_LND_MACAROON_FILE",
"CDK_MINTD_LND_FEE_PERCENT",
"CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS",
"CDK_MINTD_FAKE_WALLET_FEE_PERCENT",
"CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN",
"CDK_MINTD_FAKE_WALLET_MIN_DELAY",
"CDK_MINTD_FAKE_WALLET_MAX_DELAY",
"CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS",
"CDK_MINTD_GRPC_PAYMENT_PROCESSOR_ADDRESS",
"CDK_MINTD_GRPC_PAYMENT_PROCESSOR_PORT",
"CDK_MINTD_PROMETHEUS_ENABLED",
"CDK_MINTD_PROMETHEUS_ADDRESS",
"CDK_MINTD_PROMETHEUS_PORT",
"CDK_MINTD_MINT_MANAGEMENT_ENABLED",
"CDK_MINTD_MANAGEMENT_ADDRESS",
"CDK_MINTD_MANAGEMENT_PORT",
] {
std::env::remove_var(var);
}
}
fn load_settings_from_toml(name: &str, config_content: &str) -> Result<config::Settings> {
use std::fs;
let temp_dir = crate::test_utils::unique_temp_path(name);
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let config_path = temp_dir.join("config.toml");
fs::write(&config_path, config_content).expect("Failed to write config file");
let result = load_settings(&temp_dir, Some(config_path));
let _ = fs::remove_dir_all(&temp_dir);
result
}
fn assert_load_settings_error(config_content: &str, expected: &str) {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
let err = load_settings_from_toml("cdk_mintd_invalid_config", config_content)
.expect_err("Settings should fail validation");
assert!(
err.to_string().contains(expected),
"expected error containing `{expected}`, got `{err}`"
);
}
#[cfg(all(feature = "prometheus", feature = "fakewallet"))]
#[test]
fn test_load_settings_merges_partial_postgres_toml_with_env() {
use std::{env, fs};
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
env::remove_var(crate::env_vars::DATABASE_URL_ENV_VAR);
env::remove_var(crate::env_vars::ENV_POSTGRES_URL);
env::remove_var(crate::env_vars::ENV_PROMETHEUS_ENABLED);
env::remove_var(crate::env_vars::ENV_PROMETHEUS_ADDRESS);
env::remove_var(crate::env_vars::ENV_PROMETHEUS_PORT);
let postgres_url = "postgresql://user:password@localhost:5432/cdk_mint";
env::set_var(crate::env_vars::ENV_POSTGRES_URL, postgres_url);
let temp_dir = crate::test_utils::unique_temp_path("cdk_mintd_partial_config");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let config_path = temp_dir.join("config.toml");
let config_content = r#"
[info]
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
[database]
engine = "postgres"
[database.postgres]
tls_mode = "require"
max_connections = 30
connection_timeout_seconds = 15
[payment_backend]
backend = "fakewallet"
[prometheus]
enabled = true
address = "0.0.0.0"
port = 9090
"#;
fs::write(&config_path, config_content).expect("Failed to write config file");
let settings =
load_settings(&temp_dir, Some(config_path)).expect("Failed to load settings");
let postgres = settings
.database
.postgres
.as_ref()
.expect("Postgres config should be present");
assert_eq!(postgres.url, postgres_url);
assert_eq!(postgres.tls_mode.as_deref(), Some("require"));
let prometheus = settings
.prometheus
.as_ref()
.expect("Prometheus config should be loaded from TOML");
assert!(prometheus.enabled);
assert_eq!(prometheus.address.as_deref(), Some("0.0.0.0"));
assert_eq!(prometheus.port, Some(9090));
env::remove_var(crate::env_vars::ENV_POSTGRES_URL);
let _ = fs::remove_dir_all(&temp_dir);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_postgres_url_after_merge() {
use std::{env, fs};
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
env::remove_var(crate::env_vars::DATABASE_URL_ENV_VAR);
env::remove_var(crate::env_vars::ENV_POSTGRES_URL);
let temp_dir = crate::test_utils::unique_temp_path("cdk_mintd_invalid_config");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let config_path = temp_dir.join("config.toml");
let config_content = r#"
[info]
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
[database]
engine = "postgres"
[database.postgres]
tls_mode = "require"
[payment_backend]
backend = "fakewallet"
"#;
fs::write(&config_path, config_content).expect("Failed to write config file");
let err = load_settings(&temp_dir, Some(config_path))
.expect_err("Settings should fail validation without a Postgres URL");
assert!(err.to_string().contains("PostgreSQL URL is required"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_short_seed() {
assert_load_settings_error(
r#"
[info]
seed = "tooshort"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
"Seed in [info].seed is too short",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_signing_source() {
assert_load_settings_error(
r#"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
"No signing source configured",
);
}
#[test]
fn test_load_settings_reports_missing_payment_backend() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
"#
),
"At least one payment backend",
);
}
#[cfg(feature = "cln")]
#[test]
fn test_load_settings_reports_missing_cln_config() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "cln"
"#
),
"CLN backend selected but [cln] config section is missing",
);
}
#[cfg(feature = "lnd")]
#[test]
fn test_load_settings_reports_missing_lnd_config() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "lnd"
"#
),
"LND backend selected but [lnd] config section is missing",
);
}
#[cfg(feature = "grpc-processor")]
#[test]
fn test_load_settings_reports_missing_grpc_supported_units() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "grpcprocessor"
[grpc_processor]
addr = "http://127.0.0.1"
"#
),
"gRPC payment processor supported_units must contain at least one unit",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_fakewallet_delay_range() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
min_delay_time = 10
max_delay_time = 1
"#
),
"Fake wallet min_delay_time cannot be greater than max_delay_time",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_auth_openid_config() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[auth]
auth_enabled = true
"#
),
"Auth openid_discovery must be set",
);
}
#[test]
fn test_load_settings_reports_toml_parse_errors() {
assert_load_settings_error(
r#"
[info
mnemonic = "not valid toml"
"#,
"Failed to read config file",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_payment_backend_limit_range() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
min_mint = 10
max_mint = 1
"#
),
"Payment backend min_mint cannot be greater than max_mint",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_merges_partial_onchain_config_with_defaults() {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
let settings = load_settings_from_toml(
"cdk_mintd_partial_onchain_config",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[onchain]
onchain_backend = "fakewallet"
[fake_wallet]
"#
),
)
.expect("partial on-chain config should use defaults");
let onchain = settings.onchain.expect("on-chain config should be present");
assert_eq!(onchain.min_mint, 1.into());
assert_eq!(onchain.max_mint, 500_000.into());
assert_eq!(onchain.min_melt, 1.into());
assert_eq!(onchain.max_melt, 500_000.into());
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_onchain_mint_limit_range() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[onchain]
onchain_backend = "fakewallet"
min_mint = 10
max_mint = 1
"#
),
"On-chain min_mint cannot be greater than max_mint",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_onchain_melt_limit_range() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[onchain]
onchain_backend = "fakewallet"
min_melt = 10
max_melt = 1
"#
),
"On-chain min_melt cannot be greater than max_melt",
);
}
#[cfg(all(feature = "prometheus", feature = "fakewallet"))]
#[test]
fn test_load_settings_reports_invalid_prometheus_address() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[prometheus]
enabled = true
address = "localhost"
port = 9090
"#
),
"Invalid Prometheus address",
);
}
#[cfg(all(feature = "management-rpc", feature = "fakewallet"))]
#[test]
fn test_load_settings_reports_invalid_management_rpc_address() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[mint_management_rpc]
enabled = true
address = "localhost"
port = 8086
"#
),
"Invalid mint management RPC address",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_valid_config() {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
load_settings_from_toml(
"cdk_mintd_valid",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
)
.expect("valid config should load without error");
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_valid_config_with_insecure_signatory() {
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
load_settings_from_toml(
"cdk_mintd_valid_signatory",
r#"
[signatory]
enabled = true
allow_insecure = true
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
)
.expect("valid config with an insecure signatory should load without error");
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_rejects_signatory_without_tls() {
assert_load_settings_error(
r#"
[signatory]
enabled = true
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
"gRPC signatory TLS is not configured",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_rejects_empty_seed_before_mnemonic() {
assert_load_settings_error(
&format!(
r#"
[info]
seed = ""
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
"Seed in [info].seed must not be empty",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_mnemonic() {
assert_load_settings_error(
r#"
[info]
mnemonic = "not a valid mnemonic phrase at all"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
"Invalid mnemonic",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_listen_address() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
listen_host = "999.999.999.999"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
"Invalid mint listen address",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_auth_openid_client_id() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[auth]
auth_enabled = true
openid_discovery = "https://issuer.example.com/.well-known/openid-configuration"
"#
),
"Auth openid_client_id must be set",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_invalid_melt_limit_range() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
min_melt = 10
max_melt = 1
"#
),
"Payment backend min_melt cannot be greater than max_melt",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_fakewallet_supported_units() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
[fake_wallet]
supported_units = []
"#
),
"Fake wallet supported_units must contain at least one unit",
);
}
#[cfg(feature = "lnd")]
#[test]
fn test_load_settings_reports_missing_lnd_cert_file() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "lnd"
[lnd]
address = "127.0.0.1:10009"
"#
),
"LND cert_file must be set",
);
}
#[cfg(feature = "lnd")]
#[test]
fn test_load_settings_reports_missing_lnd_macaroon_file() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "lnd"
[lnd]
address = "127.0.0.1:10009"
cert_file = "/path/to/tls.cert"
"#
),
"LND macaroon_file must be set",
);
}
#[cfg(feature = "grpc-processor")]
#[test]
fn test_load_settings_reports_missing_grpc_processor_address() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "grpcprocessor"
[grpc_processor]
supported_units = ["sat"]
address = ""
"#
),
"gRPC payment processor address must be set",
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_load_settings_reports_missing_auth_postgres_url() {
assert_load_settings_error(
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "postgres"
[database.postgres]
url = "postgresql://user:password@localhost:5432/cdk_mint"
[payment_backend]
backend = "fakewallet"
[auth]
auth_enabled = true
openid_discovery = "https://issuer.example.com/.well-known/openid-configuration"
openid_client_id = "mintd"
"#
),
"Auth database PostgreSQL URL is required",
);
}
fn load_settings_with_env(
name: &str,
config_content: &str,
setup_env: impl FnOnce(),
) -> Result<config::Settings> {
use std::fs;
let _env_lock = crate::test_utils::env_lock();
clear_mintd_env();
let temp_dir = crate::test_utils::unique_temp_path(name);
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
let config_path = temp_dir.join("config.toml");
fs::write(&config_path, config_content).expect("Failed to write config file");
setup_env();
let result = load_settings(&temp_dir, Some(config_path));
let _ = fs::remove_dir_all(&temp_dir);
clear_mintd_env();
result
}
#[cfg(feature = "fakewallet")]
#[test]
fn env_only_auth_preserves_protected_endpoint_defaults() {
let settings = load_settings_with_env(
"cdk_mintd_env_auth_defaults",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
|| {
std::env::set_var("CDK_MINTD_AUTH_ENABLED", "true");
std::env::set_var(
"CDK_MINTD_AUTH_OPENID_DISCOVERY",
"https://issuer.example.com/.well-known/openid-configuration",
);
std::env::set_var("CDK_MINTD_AUTH_OPENID_CLIENT_ID", "mintd");
},
)
.expect("environment-only auth configuration should load");
let auth = settings.auth.expect("auth should be enabled");
assert_eq!(auth.mint, config::AuthType::Blind);
assert_eq!(auth.swap, config::AuthType::Blind);
assert_eq!(auth.restore, config::AuthType::Blind);
assert_eq!(auth.websocket_auth, config::AuthType::Blind);
}
#[cfg(feature = "lnd")]
#[test]
fn invalid_lnd_fee_percent_from_env_is_rejected() {
for (name, fee_percent) in [
("nan", "NaN"),
("negative", "-1"),
("not-a-number", "invalid"),
] {
let error = load_settings_with_env(
&format!("cdk_mintd_lnd_fee_percent_{name}"),
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
[payment_backend]
backend = "lnd"
"#
),
|| {
std::env::set_var("CDK_MINTD_LND_ADDRESS", "https://127.0.0.1:10009");
std::env::set_var("CDK_MINTD_LND_CERT_FILE", "/certs/tls.cert");
std::env::set_var("CDK_MINTD_LND_MACAROON_FILE", "/data/admin.macaroon");
std::env::set_var("CDK_MINTD_LND_FEE_PERCENT", fee_percent);
},
)
.expect_err("invalid LND fee percentage should fail startup validation");
assert!(
error.to_string().contains("CDK_MINTD_LND_FEE_PERCENT"),
"unexpected error for {fee_percent}: {error}"
);
}
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_env_var_provides_mnemonic_when_toml_has_none() {
let settings = load_settings_with_env(
"cdk_mintd_env_mnemonic",
r#"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
|| std::env::set_var("CDK_MINTD_MNEMONIC", TEST_MNEMONIC),
)
.expect("valid config with env mnemonic should load");
let mnemonic = settings
.info
.mnemonic
.expect("mnemonic should be set from env");
assert_eq!(mnemonic, TEST_MNEMONIC);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_env_var_provides_seed_when_toml_has_none() {
let seed = "a".repeat(32);
let settings = load_settings_with_env(
"cdk_mintd_env_seed",
r#"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#,
|| std::env::set_var("CDK_MINTD_SEED", &seed),
)
.expect("valid config with env seed should load");
let loaded_seed = settings.info.seed.expect("seed should be set from env");
assert_eq!(loaded_seed, seed);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_env_var_provides_payment_backend_when_toml_has_none() {
let settings = load_settings_with_env(
"cdk_mintd_env_payment_backend_only",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
[database]
engine = "sqlite"
"#
),
|| {
std::env::set_var("CDK_MINTD_PAYMENT_BACKEND", "fakewallet");
std::env::set_var("CDK_MINTD_PAYMENT_BACKEND_MIN_MINT", "10");
},
)
.expect("env-only payment backend config should load");
assert_eq!(settings.payment_backend.len(), 1);
assert_eq!(
settings.payment_backend[0].backend,
config::PaymentBackendType::FakeWallet
);
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_env_var_overrides_toml_listen_host() {
let settings = load_settings_with_env(
"cdk_mintd_env_override_listen",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
listen_host = "127.0.0.1"
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
|| std::env::set_var("CDK_MINTD_LISTEN_HOST", "0.0.0.0"),
)
.expect("config with env override should load");
assert_eq!(settings.info.listen_host, "0.0.0.0");
}
#[cfg(feature = "fakewallet")]
#[test]
fn test_env_var_overrides_toml_listen_port() {
let settings = load_settings_with_env(
"cdk_mintd_env_override_port",
&format!(
r#"
[info]
mnemonic = "{TEST_MNEMONIC}"
listen_port = 8080
[database]
engine = "sqlite"
[payment_backend]
backend = "fakewallet"
"#
),
|| std::env::set_var("CDK_MINTD_LISTEN_PORT", "9090"),
)
.expect("config with env port override should load");
assert_eq!(settings.info.listen_port, 9090);
}
}