Bläddra i källkod

feat: phoenixd ln backend

thesimplekid 7 månader sedan
förälder
incheckning
cc5efd9887

+ 2 - 1
.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,
           ]

+ 1 - 0
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 }

+ 1 - 1
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,
         )

+ 3 - 2
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,
         })
     }
 

+ 2 - 1
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,
         })
     }
 

+ 2 - 1
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,
         })
     }
 

+ 2 - 0
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

+ 4 - 0
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 = ""

+ 9 - 2
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<CurrencyUnit>,
@@ -87,6 +92,7 @@ pub struct Settings {
     pub cln: Option<Cln>,
     pub strike: Option<Strike>,
     pub lnbits: Option<LNbits>,
+    pub phoenixd: Option<Phoenixd>,
     pub fake_wallet: Option<FakeWallet>,
     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 => (),
         }
 

+ 56 - 5
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<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);
+    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]
         }

+ 24 - 0
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

+ 46 - 0
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.

+ 26 - 0
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<Error> for cdk::cdk_lightning::Error {
+    fn from(e: Error) -> Self {
+        Self::Lightning(Box::new(e))
+    }
+}

+ 262 - 0
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<Mutex<Option<tokio::sync::mpsc::Receiver<WebhookResponse>>>>,
+    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<Mutex<Option<tokio::sync::mpsc::Receiver<WebhookResponse>>>>,
+        webhook_url: String,
+    ) -> Result<Self, Error> {
+        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<WebhookResponse>,
+    ) -> anyhow::Result<Router> {
+        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<Pin<Box<dyn Stream<Item = String> + 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<PaymentQuoteResponse, Self::Err> {
+        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<Amount>,
+        _max_fee_msats: Option<Amount>,
+    ) -> Result<PayInvoiceResponse, Self::Err> {
+        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<CreateInvoiceResponse, Self::Err> {
+        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<MintQuoteState, Self::Err> {
+        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<PaymentQuoteResponse, Error> {
+        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)
+    }
+}

+ 2 - 1
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,
         })
     }
 

+ 5 - 2
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<T>(

+ 9 - 1
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?;