diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96d47bbe..37e3d06d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,11 @@ jobs: -p cdk-sqlite, -p cdk-axum, -p cdk-cln, - -p cdk-fake-wallet, + -p cdk-phoenixd, -p cdk-strike, -p cdk-lnbits -p cdk-integration-tests, + -p cdk-fake-wallet, --bin cdk-cli, --bin cdk-mintd, ] diff --git a/Cargo.toml b/Cargo.toml index 679b310f..8d6a692e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ cdk-sqlite = { version = "0.3", path = "./crates/cdk-sqlite", default-features = cdk-redb = { version = "0.3", path = "./crates/cdk-redb", default-features = false } cdk-cln = { version = "0.3", path = "./crates/cdk-cln", default-features = false } cdk-lnbits = { version = "0.3", path = "./crates/cdk-lnbits", default-features = false } +cdk-phoenixd = { version = "0.3", path = "./crates/cdk-phoenixd", default-features = false } cdk-axum = { version = "0.3", path = "./crates/cdk-axum", default-features = false } cdk-fake-wallet = { version = "0.3", path = "./crates/cdk-fake-wallet", default-features = false } cdk-strike = { version = "0.3", path = "./crates/cdk-strike", default-features = false } diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index effa8312..3704ffce 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -158,7 +158,7 @@ pub async fn get_melt_bolt11_quote( payload.request.to_string(), payload.unit, payment_quote.amount, - payment_quote.fee.into(), + payment_quote.fee, unix_time() + state.quote_ttl, payment_quote.request_lookup_id, ) diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index c2107c76..15e64011 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -45,7 +45,7 @@ pub struct Cln { } impl Cln { - /// Create new ['Cln] + /// Create new [`Cln`] pub async fn new( rpc_socket: PathBuf, fee_reserve: FeeReserve, @@ -144,7 +144,8 @@ impl MintLightning for Cln { Ok(PaymentQuoteResponse { request_lookup_id: melt_quote_request.request.payment_hash().to_string(), amount, - fee, + fee: fee.into(), + state: MeltQuoteState::Unpaid, }) } diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 174af9c7..468302e2 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -110,7 +110,8 @@ impl MintLightning for FakeWallet { Ok(PaymentQuoteResponse { request_lookup_id: melt_quote_request.request.payment_hash().to_string(), amount, - fee, + fee: fee.into(), + state: MeltQuoteState::Unpaid, }) } diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index e8d9ee30..a0855106 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -146,7 +146,8 @@ impl MintLightning for LNbits { Ok(PaymentQuoteResponse { request_lookup_id: melt_quote_request.request.payment_hash().to_string(), amount, - fee, + fee: fee.into(), + state: MeltQuoteState::Unpaid, }) } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 4876ef05..df3c0dd9 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -17,6 +17,7 @@ cdk-redb = { workspace = true, default-features = false, features = ["mint"] } cdk-sqlite = { workspace = true, default-features = false, features = ["mint"] } cdk-cln = { workspace = true, default-features = false } cdk-lnbits = { workspace = true, default-features = false } +cdk-phoenixd = { workspace = true, default-features = false } cdk-fake-wallet = { workspace = true, default-features = false } cdk-strike.workspace = true cdk-axum = { workspace = true, default-features = false } @@ -31,3 +32,4 @@ bip39.workspace = true tower-http = { version = "0.5.2", features = ["cors"] } lightning-invoice.workspace = true home.workspace = true +url.workspace = true diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 79b8a36d..0dd45dc4 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -43,3 +43,7 @@ ln_backend = "cln" # admin_api_key = "" # invoice_api_key = "" # lnbits_api = "" + +# [phoenixd] +# api_password = "" +# api_url = "" diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 37136d35..880a46f6 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -23,8 +23,7 @@ pub enum LnBackend { Strike, LNbits, FakeWallet, - // Greenlight, - // Ldk, + Phoenixd, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -53,6 +52,12 @@ pub struct Cln { pub rpc_path: PathBuf, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Phoenixd { + pub api_password: String, + pub api_url: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { pub supported_units: Vec, @@ -87,6 +92,7 @@ pub struct Settings { pub cln: Option, pub strike: Option, pub lnbits: Option, + pub phoenixd: Option, pub fake_wallet: Option, pub database: Database, } @@ -152,6 +158,7 @@ impl Settings { LnBackend::Cln => assert!(settings.cln.is_some()), LnBackend::Strike => assert!(settings.strike.is_some()), LnBackend::LNbits => assert!(settings.lnbits.is_some()), + LnBackend::Phoenixd => assert!(settings.phoenixd.is_some()), LnBackend::FakeWallet => (), } diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index f8aaff56..916a592a 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use axum::Router; use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; @@ -24,6 +24,7 @@ use cdk_axum::LnKey; use cdk_cln::Cln; use cdk_fake_wallet::FakeWallet; use cdk_lnbits::LNbits; +use cdk_phoenixd::Phoenixd; use cdk_redb::MintRedbDatabase; use cdk_sqlite::MintSqliteDatabase; use cdk_strike::Strike; @@ -34,6 +35,7 @@ use futures::StreamExt; use tokio::sync::Mutex; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; +use url::Url; mod cli; mod config; @@ -84,8 +86,8 @@ async fn main() -> anyhow::Result<()> { let mut contact_info: Option> = None; - if let Some(nostr_contact) = settings.mint_info.contact_nostr_public_key { - let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact); + if let Some(nostr_contact) = &settings.mint_info.contact_nostr_public_key { + let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact.to_string()); contact_info = match contact_info { Some(mut vec) => { @@ -96,8 +98,8 @@ async fn main() -> anyhow::Result<()> { }; } - if let Some(email_contact) = settings.mint_info.contact_email { - let email_contact = ContactInfo::new("email".to_string(), email_contact); + if let Some(email_contact) = &settings.mint_info.contact_email { + let email_contact = ContactInfo::new("email".to_string(), email_contact.to_string()); contact_info = match contact_info { Some(mut vec) => { @@ -232,6 +234,55 @@ async fn main() -> anyhow::Result<()> { ln_backends.insert(ln_key, Arc::new(lnbits)); supported_units.insert(unit, (input_fee_ppk, 64)); + vec![router] + } + LnBackend::Phoenixd => { + let api_password = settings + .clone() + .phoenixd + .expect("Checked at config load") + .api_password; + + let api_url = settings + .clone() + .phoenixd + .expect("Checked at config load") + .api_url; + + if fee_reserve.percent_fee_reserve < 0.04 { + bail!("Fee reserve is too low needs to be at least 0.02"); + } + + let webhook_endpoint = "/webhook/phoenixd"; + + let mint_url = Url::parse(&settings.info.url)?; + + let webhook_url = mint_url.join(webhook_endpoint)?.to_string(); + + let (sender, receiver) = tokio::sync::mpsc::channel(8); + + let phoenixd = Phoenixd::new( + api_password.to_string(), + api_url.to_string(), + MintMeltSettings::default(), + MintMeltSettings::default(), + fee_reserve, + Arc::new(Mutex::new(Some(receiver))), + webhook_url, + )?; + + let router = phoenixd + .create_invoice_webhook(webhook_endpoint, sender) + .await?; + + supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); + ln_backends.insert( + LnKey { + unit: CurrencyUnit::Sat, + method: PaymentMethod::Bolt11, + }, + Arc::new(phoenixd), + ); vec![router] } diff --git a/crates/cdk-phoenixd/Cargo.toml b/crates/cdk-phoenixd/Cargo.toml new file mode 100644 index 00000000..d4e27d9c --- /dev/null +++ b/crates/cdk-phoenixd/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "cdk-phoenixd" +version = { workspace = true } +edition = "2021" +authors = ["CDK Developers"] +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true # MSRV +license.workspace = true +description = "CDK ln backend for phoenixd" + +[dependencies] +async-trait.workspace = true +anyhow.workspace = true +axum.workspace = true +bitcoin.workspace = true +cdk = { workspace = true, default-features = false, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +phoenixd-rs = "0.2.0" +# phoenixd-rs = { path = "../../../../phoenixd-rs" } +uuid.workspace = true diff --git a/crates/cdk-phoenixd/README.md b/crates/cdk-phoenixd/README.md new file mode 100644 index 00000000..046566f9 --- /dev/null +++ b/crates/cdk-phoenixd/README.md @@ -0,0 +1,46 @@ +# cdk-phoenixd + +## Run phoenixd + +The `phoenixd` node is included in the cdk and needs to be run separately. +Get started here: [Phoenixd Server Documentation](https://phoenix.acinq.co/server/get-started) + +## Start Phoenixd + +By default, `phoenixd` will run with auto-liquidity enabled. While this simplifies channel management, it makes fees non-deterministic, which is not recommended for most scenarios. However, it is necessary to start with auto-liquidity enabled in order to open a channel and get started. + +Start the node with auto-liquidity enabled as documented by [Phoenixd](https://phoenix.acinq.co/server/get-started): +```sh +./phoenixd +``` + +> **Note:** By default the `auto-liquidity` will open a channel of 2m sats depending on the size of mint you plan to run you may want to increase this by setting the `--auto-liquidity` flag to `5m` or `10m`. + +## Open Channel + +Once the node is running, create an invoice using the phoenixd-cli to fund your node. A portion of this deposit will go to ACINQ as a fee for the provided liquidity, and a portion will cover the mining fee. These two fees cannot be refunded or withdrawn from the node. More on fees can be found [here](https://phoenix.acinq.co/server/auto-liquidity#fees). The remainder will stay as the node balance and can be withdrawn later. +```sh +./phoenix-cli createinvoice \ + --description "Fund Node" \ + --amountSat xxxxx +``` + +> **Note:** The amount above should be set depending on the size of the mint you would like to run as it will determine the size of the channel and amount of liquidity. + +## Check Channel state + +After paying the invoice view that a channal has been opened. +```sh +./phoenix-cli listchannels +``` + +## Restart Phoenixd without `auto-liquidity` + +Now that the node has a channel, it is recommended to stop the node and restart it without auto-liquidity. This will prevent phoenixd from opening new channels and incurring additional fees. +```sh +./phoenixd --auto-liquidity off +``` + +## Start cashu-mintd + +Once the node is running following the [cashu-mintd](../cdk-mintd/README.md) to start the mint. by default the `api_url` will be `http://127.0.0.1:9740` and the `api_password` can be found in `~/.phoenix/phoenix.conf` these will need to be set in the `cdk-mintd` config file. diff --git a/crates/cdk-phoenixd/src/error.rs b/crates/cdk-phoenixd/src/error.rs new file mode 100644 index 00000000..8eaf60ef --- /dev/null +++ b/crates/cdk-phoenixd/src/error.rs @@ -0,0 +1,26 @@ +//! Error for phoenixd ln backend + +use thiserror::Error; + +/// Phoenixd Error +#[derive(Debug, Error)] +pub enum Error { + /// Invoice amount not defined + #[error("Unknown invoice amount")] + UnknownInvoiceAmount, + /// Unknown invoice + #[error("Unknown invoice")] + UnknownInvoice, + /// Unsupported unit + #[error("Unit Unsupported")] + UnsupportedUnit, + /// Anyhow error + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +impl From for cdk::cdk_lightning::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-phoenixd/src/lib.rs b/crates/cdk-phoenixd/src/lib.rs new file mode 100644 index 00000000..f131467e --- /dev/null +++ b/crates/cdk-phoenixd/src/lib.rs @@ -0,0 +1,262 @@ +//! CDK lightning backend for Phoenixd + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::anyhow; +use async_trait::async_trait; +use axum::Router; +use cdk::amount::Amount; +use cdk::cdk_lightning::{ + self, to_unit, CreateInvoiceResponse, MintLightning, MintMeltSettings, PayInvoiceResponse, + PaymentQuoteResponse, Settings, MSAT_IN_SAT, +}; +use cdk::mint::FeeReserve; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; +use cdk::{mint, Bolt11Invoice}; +use error::Error; +use futures::{Stream, StreamExt}; +use phoenixd_rs::webhooks::WebhookResponse; +use phoenixd_rs::{InvoiceRequest, Phoenixd as PhoenixdApi}; +use tokio::sync::Mutex; + +pub mod error; + +/// Phoenixd +#[derive(Clone)] +pub struct Phoenixd { + mint_settings: MintMeltSettings, + melt_settings: MintMeltSettings, + phoenixd_api: PhoenixdApi, + fee_reserve: FeeReserve, + receiver: Arc>>>, + webhook_url: String, +} + +impl Phoenixd { + /// Create new [`Phoenixd`] wallet + pub fn new( + api_password: String, + api_url: String, + mint_settings: MintMeltSettings, + melt_settings: MintMeltSettings, + fee_reserve: FeeReserve, + receiver: Arc>>>, + webhook_url: String, + ) -> Result { + let phoenixd = PhoenixdApi::new(&api_password, &api_url)?; + Ok(Self { + mint_settings, + melt_settings, + phoenixd_api: phoenixd, + fee_reserve, + receiver, + webhook_url, + }) + } + + /// Create invoice webhook + pub async fn create_invoice_webhook( + &self, + webhook_endpoint: &str, + sender: tokio::sync::mpsc::Sender, + ) -> anyhow::Result { + self.phoenixd_api + .create_invoice_webhook_router(webhook_endpoint, sender) + .await + } +} + +#[async_trait] +impl MintLightning for Phoenixd { + type Err = cdk_lightning::Error; + + fn get_settings(&self) -> Settings { + Settings { + mpp: false, + unit: CurrencyUnit::Sat, + mint_settings: self.mint_settings, + melt_settings: self.melt_settings, + } + } + + async fn wait_any_invoice( + &self, + ) -> Result + Send>>, Self::Err> { + let receiver = self + .receiver + .lock() + .await + .take() + .ok_or(anyhow!("No receiver"))?; + + let phoenixd_api = self.phoenixd_api.clone(); + + Ok(futures::stream::unfold( + (receiver, phoenixd_api), + |(mut receiver, phoenixd_api)| async move { + match receiver.recv().await { + Some(msg) => { + let check = phoenixd_api.get_incoming_invoice(&msg.payment_hash).await; + + match check { + Ok(state) => { + if state.is_paid { + Some((msg.payment_hash, (receiver, phoenixd_api))) + } else { + None + } + } + _ => None, + } + } + None => None, + } + }, + ) + .boxed()) + } + + async fn get_payment_quote( + &self, + melt_quote_request: &MeltQuoteBolt11Request, + ) -> Result { + if CurrencyUnit::Sat != melt_quote_request.unit { + return Err(Error::UnsupportedUnit.into()); + } + + let invoice_amount_msat = melt_quote_request + .request + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)?; + + let amount = to_unit( + invoice_amount_msat, + &CurrencyUnit::Msat, + &melt_quote_request.unit, + )?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let mut fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + // Fee in phoenixd is always 0.04 + 4 sat + fee += 4; + + Ok(PaymentQuoteResponse { + request_lookup_id: melt_quote_request.request.payment_hash().to_string(), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + }) + } + + async fn pay_invoice( + &self, + melt_quote: mint::MeltQuote, + partial_amount: Option, + _max_fee_msats: Option, + ) -> Result { + let pay_response = self + .phoenixd_api + .pay_bolt11_invoice(&melt_quote.request, partial_amount.map(|a| a.into())) + .await?; + + // The pay response does not include the fee paided to Aciq so we check it here + let check_outgoing_response = self + .check_outgoing_invoice(&pay_response.payment_id) + .await?; + + if check_outgoing_response.state != MeltQuoteState::Paid { + return Err(anyhow!("Invoice is not paid").into()); + } + + let total_spent_sats = check_outgoing_response.fee + check_outgoing_response.amount; + + let bolt11: Bolt11Invoice = melt_quote.request.parse()?; + + Ok(PayInvoiceResponse { + payment_hash: bolt11.payment_hash().to_string(), + payment_preimage: Some(pay_response.payment_preimage), + status: MeltQuoteState::Paid, + total_spent: total_spent_sats, + }) + } + + async fn create_invoice( + &self, + amount: Amount, + unit: &CurrencyUnit, + description: String, + _unix_expiry: u64, + ) -> Result { + let amount_sat = to_unit(amount, unit, &CurrencyUnit::Sat)?; + + let invoice_request = InvoiceRequest { + external_id: None, + description: Some(description), + description_hash: None, + amount_sat: amount_sat.into(), + webhook_url: Some(self.webhook_url.clone()), + }; + + let create_invoice_response = self.phoenixd_api.create_invoice(invoice_request).await?; + + let bolt11: Bolt11Invoice = create_invoice_response.serialized.parse()?; + let expiry = bolt11.expires_at().map(|t| t.as_secs()); + + Ok(CreateInvoiceResponse { + request_lookup_id: create_invoice_response.payment_hash, + request: bolt11.clone(), + expiry, + }) + } + + async fn check_invoice_status(&self, payment_hash: &str) -> Result { + let invoice = self.phoenixd_api.get_incoming_invoice(payment_hash).await?; + + let state = match invoice.is_paid { + true => MintQuoteState::Paid, + false => MintQuoteState::Unpaid, + }; + + Ok(state) + } +} + +impl Phoenixd { + /// Check the status of an outgooing invoice + // TODO: This should likely bee added to the trait. Both CLN and PhD use a form of it + async fn check_outgoing_invoice( + &self, + payment_hash: &str, + ) -> Result { + let res = self.phoenixd_api.get_outgoing_invoice(payment_hash).await?; + + // Phenixd gives fees in msats so we need to round up to the nearst sat + let fee_sats = (res.fees + 999) / MSAT_IN_SAT; + + let state = match res.is_paid { + true => MeltQuoteState::Paid, + false => MeltQuoteState::Unpaid, + }; + + let quote_response = PaymentQuoteResponse { + request_lookup_id: res.payment_hash, + amount: res.sent.into(), + fee: fee_sats.into(), + state, + }; + + Ok(quote_response) + } +} diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index 787faeed..42bdcc44 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -143,7 +143,8 @@ impl MintLightning for Strike { Ok(PaymentQuoteResponse { request_lookup_id: quote.payment_quote_id, amount: from_strike_amount(quote.amount, &melt_quote_request.unit)?.into(), - fee, + fee: fee.into(), + state: MeltQuoteState::Unpaid, }) } diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs index 53a5d37b..007f2014 100644 --- a/crates/cdk/src/cdk_lightning/mod.rs +++ b/crates/cdk/src/cdk_lightning/mod.rs @@ -115,7 +115,9 @@ pub struct PaymentQuoteResponse { /// Amount pub amount: Amount, /// Fee required for melt - pub fee: u64, + pub fee: Amount, + /// Status + pub state: MeltQuoteState, } /// Ln backend settings @@ -152,7 +154,8 @@ impl Default for MintMeltSettings { } } -const MSAT_IN_SAT: u64 = 1000; +/// Msats in sat +pub const MSAT_IN_SAT: u64 = 1000; /// Helper function to convert units pub fn to_unit( diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 214fa020..8e317712 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -380,7 +380,15 @@ impl Mint { amount, fee_reserve, expiry, - request_lookup_id, + request_lookup_id.clone(), + ); + + tracing::debug!( + "New melt quote {} for {} {} with request id {}", + quote.id, + amount, + unit, + request_lookup_id ); self.localstore.add_melt_quote(quote.clone()).await?;