Files
cdk/crates/cdk-mintd/src/main.rs
thesimplekid bc9fad9e0e feat: strike api for mint backend
feat: Use mint melt settings
2024-07-22 16:16:05 +01:00

413 lines
12 KiB
Rust

//! CDK Mint Server
#![warn(missing_docs)]
#![warn(rustdoc::bare_urls)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use axum::Router;
use bip39::Mnemonic;
use cdk::cdk_database::{self, MintDatabase};
use cdk::cdk_lightning;
use cdk::cdk_lightning::{MintLightning, MintMeltSettings};
use cdk::mint::{FeeReserve, Mint};
use cdk::nuts::{
nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings,
MintVersion, MppMethodSettings, Nuts, PaymentMethod,
};
use cdk_axum::LnKey;
use cdk_cln::Cln;
use cdk_fake_wallet::FakeWallet;
use cdk_redb::MintRedbDatabase;
use cdk_sqlite::MintSqliteDatabase;
use cdk_strike::Strike;
use clap::Parser;
use cli::CLIArgs;
use config::{DatabaseEngine, LnBackend};
use futures::StreamExt;
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
mod cli;
mod config;
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
const DEFAULT_QUOTE_TTL_SECS: u64 = 1800;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let default_filter = "debug";
let sqlx_filter = "sqlx=warn";
let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
tracing_subscriber::fmt().with_env_filter(env_filter).init();
let args = CLIArgs::parse();
let work_dir = match args.work_dir {
Some(w) => w,
None => work_dir()?,
};
// get config file name from args
let config_file_arg = match args.config {
Some(c) => c,
None => work_dir.join("config.toml"),
};
let settings = config::Settings::new(&Some(config_file_arg));
let localstore: Arc<dyn MintDatabase<Err = cdk_database::Error> + Send + Sync> =
match settings.database.engine {
DatabaseEngine::Sqlite => {
let sql_db_path = work_dir.join("cdk-mintd.sqlite");
let sqlite_db = MintSqliteDatabase::new(&sql_db_path).await?;
sqlite_db.migrate().await;
Arc::new(sqlite_db)
}
DatabaseEngine::Redb => {
let redb_path = work_dir.join("cdk-mintd.redb");
Arc::new(MintRedbDatabase::new(&redb_path)?)
}
};
let mut contact_info: Option<Vec<ContactInfo>> = None;
if let Some(nostr_contact) = settings.mint_info.contact_nostr_public_key {
let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact);
contact_info = match contact_info {
Some(mut vec) => {
vec.push(nostr_contact);
Some(vec)
}
None => Some(vec![nostr_contact]),
};
}
if let Some(email_contact) = settings.mint_info.contact_email {
let email_contact = ContactInfo::new("email".to_string(), email_contact);
contact_info = match contact_info {
Some(mut vec) => {
vec.push(email_contact);
Some(vec)
}
None => Some(vec![email_contact]),
};
}
let mint_version = MintVersion::new(
"cdk-mintd".to_string(),
CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(),
);
let relative_ln_fee = settings.ln.fee_percent;
let absolute_ln_fee_reserve = settings.ln.reserve_fee_min;
let fee_reserve = FeeReserve {
min_fee_reserve: absolute_ln_fee_reserve,
percent_fee_reserve: relative_ln_fee,
};
let (ln, ln_router): (
Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>,
Option<Router>,
) = match settings.ln.ln_backend {
LnBackend::Cln => {
let cln_socket = expand_path(
settings
.ln
.cln_path
.clone()
.ok_or(anyhow!("cln socket not defined"))?
.to_str()
.ok_or(anyhow!("cln socket not defined"))?,
)
.ok_or(anyhow!("cln socket not defined"))?;
(
Arc::new(
Cln::new(
cln_socket,
fee_reserve,
MintMeltSettings::default(),
MintMeltSettings::default(),
)
.await?,
),
None,
)
}
LnBackend::Strike => {
let api_key = settings
.ln
.strike_api_key
.expect("Checked when validaing config");
// Channel used for strike web hook
let (sender, receiver) = tokio::sync::mpsc::channel(8);
let webhook_endpoint = "/webhook/invoice";
let webhook_url = format!("{}{}", settings.info.url, webhook_endpoint);
let strike = Strike::new(
api_key,
MintMeltSettings::default(),
MintMeltSettings::default(),
CurrencyUnit::Sat,
Arc::new(Mutex::new(Some(receiver))),
webhook_url,
)
.await?;
let router = strike
.create_invoice_webhook(webhook_endpoint, sender)
.await?;
(Arc::new(strike), Some(router))
}
LnBackend::FakeWallet => (
Arc::new(FakeWallet::new(
fee_reserve,
MintMeltSettings::default(),
MintMeltSettings::default(),
)),
None,
),
};
let mut ln_backends = HashMap::new();
ln_backends.insert(
LnKey::new(CurrencyUnit::Sat, PaymentMethod::Bolt11),
Arc::clone(&ln),
);
let (nut04_settings, nut05_settings, mpp_settings): (
nut04::Settings,
nut05::Settings,
Vec<MppMethodSettings>,
) = ln_backends.iter().fold(
(
nut04::Settings::new(vec![], false),
nut05::Settings::new(vec![], false),
Vec::new(),
),
|(mut nut_04, mut nut_05, mut mpp), (key, ln)| {
let settings = ln.get_settings();
let m = MppMethodSettings {
method: key.method.clone(),
unit: key.unit,
mpp: settings.mpp,
};
let n4 = MintMethodSettings {
method: key.method.clone(),
unit: key.unit,
min_amount: Some(settings.mint_settings.min_amount),
max_amount: Some(settings.mint_settings.max_amount),
};
let n5 = MeltMethodSettings {
method: key.method.clone(),
unit: key.unit,
min_amount: Some(settings.melt_settings.min_amount),
max_amount: Some(settings.melt_settings.max_amount),
};
nut_04.methods.push(n4);
nut_05.methods.push(n5);
mpp.push(m);
(nut_04, nut_05, mpp)
},
);
let nuts = Nuts::new()
.nut04(nut04_settings)
.nut05(nut05_settings)
.nut07(true)
.nut08(true)
.nut09(true)
.nut10(true)
.nut11(true)
.nut12(true)
.nut14(true)
.nut15(mpp_settings);
let mut mint_info = MintInfo::new()
.name(settings.mint_info.name)
.version(mint_version)
.description(settings.mint_info.description)
.nuts(nuts);
if let Some(long_description) = &settings.mint_info.description_long {
mint_info = mint_info.long_description(long_description);
}
if let Some(contact_info) = contact_info {
mint_info = mint_info.contact_info(contact_info);
}
if let Some(pubkey) = settings.mint_info.pubkey {
mint_info = mint_info.pubkey(pubkey);
}
if let Some(motd) = settings.mint_info.motd {
mint_info = mint_info.motd(motd);
}
let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?;
let input_fee_ppk = settings.info.input_fee_ppk.unwrap_or(0);
let mut supported_units = HashMap::new();
supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64));
let mint = Mint::new(
&settings.info.url,
&mnemonic.to_seed_normalized(""),
mint_info,
localstore,
supported_units,
)
.await?;
let mint = Arc::new(mint);
// Check the status of any mint quotes that are pending
// In the event that the mint server is down but the ln node is not
// it is possible that a mint quote was paid but the mint has not been updated
// this will check and update the mint state of those quotes
check_pending_quotes(Arc::clone(&mint), Arc::clone(&ln)).await?;
let mint_url = settings.info.url;
let listen_addr = settings.info.listen_host;
let listen_port = settings.info.listen_port;
let quote_ttl = settings
.info
.seconds_quote_is_valid_for
.unwrap_or(DEFAULT_QUOTE_TTL_SECS);
let v1_service =
cdk_axum::create_mint_router(&mint_url, Arc::clone(&mint), ln_backends, quote_ttl).await?;
let mint_service = Router::new()
.nest("/", v1_service)
.layer(CorsLayer::permissive());
let mint_service = match ln_router {
Some(ln_router) => mint_service.nest("/", ln_router),
None => mint_service,
};
// Spawn task to wait for invoces to be paid and update mint quotes
tokio::spawn(async move {
loop {
match ln.wait_any_invoice().await {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) =
handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await
{
tracing::warn!("{:?}", err);
}
}
}
Err(err) => {
tracing::warn!("Could not get invoice stream: {}", err);
}
}
}
});
let listener =
tokio::net::TcpListener::bind(format!("{}:{}", listen_addr, listen_port)).await?;
axum::serve(listener, mint_service).await?;
Ok(())
}
/// Update mint quote when called for a paid invoice
async fn handle_paid_invoice(mint: Arc<Mint>, request_lookup_id: &str) -> Result<()> {
tracing::debug!("Invoice with lookup id paid: {}", request_lookup_id);
if let Ok(Some(mint_quote)) = mint
.localstore
.get_mint_quote_by_request_lookup_id(request_lookup_id)
.await
{
tracing::debug!(
"Quote {} paid by lookup id {}",
mint_quote.id,
request_lookup_id
);
mint.localstore
.update_mint_quote_state(&mint_quote.id, cdk::nuts::MintQuoteState::Paid)
.await?;
}
Ok(())
}
/// Used on mint start up to check status of all pending mint quotes
async fn check_pending_quotes(
mint: Arc<Mint>,
ln: Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>,
) -> Result<()> {
let mut pending_quotes = mint.get_pending_mint_quotes().await?;
tracing::trace!("There are {} pending mint quotes.", pending_quotes.len());
let mut unpaid_quotes = mint.get_unpaid_mint_quotes().await?;
tracing::trace!("There are {} unpaid mint quotes.", unpaid_quotes.len());
unpaid_quotes.append(&mut pending_quotes);
for quote in unpaid_quotes {
tracing::trace!("Checking status of mint quote: {}", quote.id);
let lookup_id = quote.request_lookup_id;
let state = ln.check_invoice_status(&lookup_id).await?;
if state != quote.state {
tracing::trace!("Mintquote status changed: {}", quote.id);
mint.localstore
.update_mint_quote_state(&quote.id, state)
.await?;
}
}
Ok(())
}
fn expand_path(path: &str) -> Option<PathBuf> {
if path.starts_with('~') {
if let Some(home_dir) = home::home_dir().as_mut() {
let remainder = &path[2..];
home_dir.push(remainder);
let expanded_path = home_dir;
Some(expanded_path.clone())
} else {
None
}
} else {
Some(PathBuf::from(path))
}
}
fn work_dir() -> Result<PathBuf> {
let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?;
Ok(home_dir.join(".cdk-mintd"))
}