feat: phoenixd ln backend

This commit is contained in:
thesimplekid
2024-08-06 12:26:39 -04:00
parent 8ee5f62d62
commit cc5efd9887
17 changed files with 456 additions and 17 deletions

View File

@@ -34,10 +34,11 @@ jobs:
-p cdk-sqlite, -p cdk-sqlite,
-p cdk-axum, -p cdk-axum,
-p cdk-cln, -p cdk-cln,
-p cdk-fake-wallet, -p cdk-phoenixd,
-p cdk-strike, -p cdk-strike,
-p cdk-lnbits -p cdk-lnbits
-p cdk-integration-tests, -p cdk-integration-tests,
-p cdk-fake-wallet,
--bin cdk-cli, --bin cdk-cli,
--bin cdk-mintd, --bin cdk-mintd,
] ]

View File

@@ -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-redb = { version = "0.3", path = "./crates/cdk-redb", default-features = false }
cdk-cln = { version = "0.3", path = "./crates/cdk-cln", 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-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-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-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 } cdk-strike = { version = "0.3", path = "./crates/cdk-strike", default-features = false }

View File

@@ -158,7 +158,7 @@ pub async fn get_melt_bolt11_quote(
payload.request.to_string(), payload.request.to_string(),
payload.unit, payload.unit,
payment_quote.amount, payment_quote.amount,
payment_quote.fee.into(), payment_quote.fee,
unix_time() + state.quote_ttl, unix_time() + state.quote_ttl,
payment_quote.request_lookup_id, payment_quote.request_lookup_id,
) )

View File

@@ -45,7 +45,7 @@ pub struct Cln {
} }
impl Cln { impl Cln {
/// Create new ['Cln] /// Create new [`Cln`]
pub async fn new( pub async fn new(
rpc_socket: PathBuf, rpc_socket: PathBuf,
fee_reserve: FeeReserve, fee_reserve: FeeReserve,
@@ -144,7 +144,8 @@ impl MintLightning for Cln {
Ok(PaymentQuoteResponse { Ok(PaymentQuoteResponse {
request_lookup_id: melt_quote_request.request.payment_hash().to_string(), request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
amount, amount,
fee, fee: fee.into(),
state: MeltQuoteState::Unpaid,
}) })
} }

View File

@@ -110,7 +110,8 @@ impl MintLightning for FakeWallet {
Ok(PaymentQuoteResponse { Ok(PaymentQuoteResponse {
request_lookup_id: melt_quote_request.request.payment_hash().to_string(), request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
amount, amount,
fee, fee: fee.into(),
state: MeltQuoteState::Unpaid,
}) })
} }

View File

@@ -146,7 +146,8 @@ impl MintLightning for LNbits {
Ok(PaymentQuoteResponse { Ok(PaymentQuoteResponse {
request_lookup_id: melt_quote_request.request.payment_hash().to_string(), request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
amount, amount,
fee, fee: fee.into(),
state: MeltQuoteState::Unpaid,
}) })
} }

View File

@@ -17,6 +17,7 @@ cdk-redb = { workspace = true, default-features = false, features = ["mint"] }
cdk-sqlite = { workspace = true, default-features = false, features = ["mint"] } cdk-sqlite = { workspace = true, default-features = false, features = ["mint"] }
cdk-cln = { workspace = true, default-features = false } cdk-cln = { workspace = true, default-features = false }
cdk-lnbits = { 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-fake-wallet = { workspace = true, default-features = false }
cdk-strike.workspace = true cdk-strike.workspace = true
cdk-axum = { workspace = true, default-features = false } cdk-axum = { workspace = true, default-features = false }
@@ -31,3 +32,4 @@ bip39.workspace = true
tower-http = { version = "0.5.2", features = ["cors"] } tower-http = { version = "0.5.2", features = ["cors"] }
lightning-invoice.workspace = true lightning-invoice.workspace = true
home.workspace = true home.workspace = true
url.workspace = true

View File

@@ -43,3 +43,7 @@ ln_backend = "cln"
# admin_api_key = "" # admin_api_key = ""
# invoice_api_key = "" # invoice_api_key = ""
# lnbits_api = "" # lnbits_api = ""
# [phoenixd]
# api_password = ""
# api_url = ""

View File

@@ -23,8 +23,7 @@ pub enum LnBackend {
Strike, Strike,
LNbits, LNbits,
FakeWallet, FakeWallet,
// Greenlight, Phoenixd,
// Ldk,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -53,6 +52,12 @@ pub struct Cln {
pub rpc_path: PathBuf, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FakeWallet { pub struct FakeWallet {
pub supported_units: Vec<CurrencyUnit>, pub supported_units: Vec<CurrencyUnit>,
@@ -87,6 +92,7 @@ pub struct Settings {
pub cln: Option<Cln>, pub cln: Option<Cln>,
pub strike: Option<Strike>, pub strike: Option<Strike>,
pub lnbits: Option<LNbits>, pub lnbits: Option<LNbits>,
pub phoenixd: Option<Phoenixd>,
pub fake_wallet: Option<FakeWallet>, pub fake_wallet: Option<FakeWallet>,
pub database: Database, pub database: Database,
} }
@@ -152,6 +158,7 @@ impl Settings {
LnBackend::Cln => assert!(settings.cln.is_some()), LnBackend::Cln => assert!(settings.cln.is_some()),
LnBackend::Strike => assert!(settings.strike.is_some()), LnBackend::Strike => assert!(settings.strike.is_some()),
LnBackend::LNbits => assert!(settings.lnbits.is_some()), LnBackend::LNbits => assert!(settings.lnbits.is_some()),
LnBackend::Phoenixd => assert!(settings.phoenixd.is_some()),
LnBackend::FakeWallet => (), LnBackend::FakeWallet => (),
} }

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, bail, Result};
use axum::Router; use axum::Router;
use bip39::Mnemonic; use bip39::Mnemonic;
use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_database::{self, MintDatabase};
@@ -24,6 +24,7 @@ use cdk_axum::LnKey;
use cdk_cln::Cln; use cdk_cln::Cln;
use cdk_fake_wallet::FakeWallet; use cdk_fake_wallet::FakeWallet;
use cdk_lnbits::LNbits; use cdk_lnbits::LNbits;
use cdk_phoenixd::Phoenixd;
use cdk_redb::MintRedbDatabase; use cdk_redb::MintRedbDatabase;
use cdk_sqlite::MintSqliteDatabase; use cdk_sqlite::MintSqliteDatabase;
use cdk_strike::Strike; use cdk_strike::Strike;
@@ -34,6 +35,7 @@ use futures::StreamExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use url::Url;
mod cli; mod cli;
mod config; mod config;
@@ -84,8 +86,8 @@ async fn main() -> anyhow::Result<()> {
let mut contact_info: Option<Vec<ContactInfo>> = None; let mut contact_info: Option<Vec<ContactInfo>> = None;
if let Some(nostr_contact) = settings.mint_info.contact_nostr_public_key { if let Some(nostr_contact) = &settings.mint_info.contact_nostr_public_key {
let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact); let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact.to_string());
contact_info = match contact_info { contact_info = match contact_info {
Some(mut vec) => { Some(mut vec) => {
@@ -96,8 +98,8 @@ async fn main() -> anyhow::Result<()> {
}; };
} }
if let Some(email_contact) = settings.mint_info.contact_email { if let Some(email_contact) = &settings.mint_info.contact_email {
let email_contact = ContactInfo::new("email".to_string(), email_contact); let email_contact = ContactInfo::new("email".to_string(), email_contact.to_string());
contact_info = match contact_info { contact_info = match contact_info {
Some(mut vec) => { Some(mut vec) => {
@@ -232,6 +234,55 @@ async fn main() -> anyhow::Result<()> {
ln_backends.insert(ln_key, Arc::new(lnbits)); ln_backends.insert(ln_key, Arc::new(lnbits));
supported_units.insert(unit, (input_fee_ppk, 64)); 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] vec![router]
} }

View File

@@ -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

View File

@@ -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.

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -143,7 +143,8 @@ impl MintLightning for Strike {
Ok(PaymentQuoteResponse { Ok(PaymentQuoteResponse {
request_lookup_id: quote.payment_quote_id, request_lookup_id: quote.payment_quote_id,
amount: from_strike_amount(quote.amount, &melt_quote_request.unit)?.into(), amount: from_strike_amount(quote.amount, &melt_quote_request.unit)?.into(),
fee, fee: fee.into(),
state: MeltQuoteState::Unpaid,
}) })
} }

View File

@@ -115,7 +115,9 @@ pub struct PaymentQuoteResponse {
/// Amount /// Amount
pub amount: Amount, pub amount: Amount,
/// Fee required for melt /// Fee required for melt
pub fee: u64, pub fee: Amount,
/// Status
pub state: MeltQuoteState,
} }
/// Ln backend settings /// 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 /// Helper function to convert units
pub fn to_unit<T>( pub fn to_unit<T>(

View File

@@ -380,7 +380,15 @@ impl Mint {
amount, amount,
fee_reserve, fee_reserve,
expiry, 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?; self.localstore.add_melt_quote(quote.clone()).await?;