diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aba811c17..b5ae8a74a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,11 @@ jobs: -p cdk-sqlite, -p cdk-axum, -p cdk-cln, - -p cdk-fake-wallet, + -p cdk-phoenixd, -p cdk-strike, + -p cdk-fake-wallet, --bin cdk-cli, --bin cdk-mintd, - --examples ] steps: - name: Checkout diff --git a/Cargo.toml b/Cargo.toml index db5a5cf25..d4982fb72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ cdk-rexie = { version = "0.3", path = "./crates/cdk-rexie", default-features = f cdk-sqlite = { version = "0.3", path = "./crates/cdk-sqlite", default-features = false } 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-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 effa8312f..3704ffceb 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 c2107c76a..15e640113 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 174af9c7f..468302e2f 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-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index c53e1d65f..d33d6a1ac 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -16,6 +16,7 @@ cdk = { workspace = true, default-features = false, features = ["mint"] } 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-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 } @@ -30,3 +31,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 bdf3072d8..784c9ad4a 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -37,3 +37,7 @@ ln_backend = "cln" # api_key="" # Optional default sats # supported_units=[""] + +# [phoenixd] +# api_password = "" +# api_url = "" diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index ace9c6548..0f6f8e71c 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -22,8 +22,7 @@ pub enum LnBackend { Cln, Strike, FakeWallet, - // Greenlight, - // Ldk, + Phoenixd, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -45,6 +44,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, @@ -78,6 +83,7 @@ pub struct Settings { pub ln: Ln, pub cln: Option, pub strike: Option, + pub phoenixd: Option, pub fake_wallet: Option, pub database: Database, } @@ -143,6 +149,7 @@ impl Settings { LnBackend::Cln => assert!(settings.cln.is_some()), LnBackend::FakeWallet => (), LnBackend::Strike => assert!(settings.strike.is_some()), + LnBackend::Phoenixd => assert!(settings.phoenixd.is_some()), } Ok(settings) diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 74faeed49..e1e80346f 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}; @@ -22,6 +22,7 @@ use cdk::nuts::{ use cdk_axum::LnKey; use cdk_cln::Cln; use cdk_fake_wallet::FakeWallet; +use cdk_phoenixd::Phoenixd; use cdk_redb::MintRedbDatabase; use cdk_sqlite::MintSqliteDatabase; use cdk_strike::Strike; @@ -32,6 +33,7 @@ use futures::StreamExt; use tokio::sync::Mutex; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; +use url::Url; mod cli; mod config; @@ -82,8 +84,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) => { @@ -94,8 +96,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) => { @@ -194,6 +196,55 @@ async fn main() -> anyhow::Result<()> { routers } + 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 < 2.0 { + bail!("Fee reserve is too low"); + } + + 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] + } LnBackend::FakeWallet => { let units = settings.fake_wallet.unwrap_or_default().supported_units; diff --git a/crates/cdk-phoenixd/Cargo.toml b/crates/cdk-phoenixd/Cargo.toml new file mode 100644 index 000000000..d4e27d9c6 --- /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 000000000..c104e7bb8 --- /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 30000 +``` + +> **Note:** The required funds to set up a node could could be greater if on-chain fees are high. + +## 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 000000000..8eaf60efd --- /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 000000000..6acb7daf3 --- /dev/null +++ b/crates/cdk-phoenixd/src/lib.rs @@ -0,0 +1,259 @@ +//! 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 fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + 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 787faeed1..42bdcc44c 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 53a5d37b1..007f20144 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/misc/scripts/check-crates.sh b/misc/scripts/check-crates.sh index 34a38e98d..5c230b9a8 100755 --- a/misc/scripts/check-crates.sh +++ b/misc/scripts/check-crates.sh @@ -33,6 +33,7 @@ buildargs=( "-p cdk-sqlite --no-default-features --features mint" "-p cdk-sqlite --no-default-features --features wallet" "-p cdk-cln" + "-p cdk-phoenixd" "-p cdk-axum" "-p cdk-fake-wallet" "-p cdk-strike" diff --git a/misc/scripts/check-docs.sh b/misc/scripts/check-docs.sh index 08069633d..984af7616 100755 --- a/misc/scripts/check-docs.sh +++ b/misc/scripts/check-docs.sh @@ -4,6 +4,12 @@ set -euo pipefail buildargs=( "-p cdk" + "-p cdk-axum" + "-p cdk-cln" + "-p cdk-fake-wallet" + "-p cdk-phoenixd" + "-p cdk-redb" + "-p cdk-sqlite" ) for arg in "${buildargs[@]}"; do