Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions crates/cashu/src/nuts/auth/nut22.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

use std::fmt;

use bitcoin::base64::alphabet;
use bitcoin::base64::engine::general_purpose::{self, GeneralPurposeConfig};
use bitcoin::base64::engine::GeneralPurpose;
use bitcoin::base64::Engine;
use bitcoin::base64::{alphabet, Engine};
use serde::{Deserialize, Serialize};
use thiserror::Error;

Expand Down
3 changes: 3 additions & 0 deletions crates/cdk-cln/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub enum Error {
/// Database Error
#[error("Database error: {0}")]
Database(String),
/// Serde JSON Error
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
}

impl From<Error> for cdk_common::payment::Error {
Expand Down
46 changes: 30 additions & 16 deletions crates/cdk-cln/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use cln_rpc::model::requests::{
OfferRequest, PayRequest, WaitanyinvoiceRequest,
};
use cln_rpc::model::responses::{
DecodeResponse, ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus,
PayStatus, WaitanyinvoiceResponse, WaitanyinvoiceStatus,
DecodeResponse, InvoiceResponse, ListinvoicesInvoices, ListinvoicesInvoicesStatus,
ListpaysPaysStatus, PayStatus, WaitanyinvoiceResponse, WaitanyinvoiceStatus,
};
use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny, Sha256};
use cln_rpc::ClnRpc;
Expand All @@ -52,6 +52,7 @@ const LAST_PAY_INDEX_KV_KEY: &str = "last_pay_index";
pub struct Cln {
rpc_socket: PathBuf,
fee_reserve: FeeReserve,
expose_private_channels: bool,
wait_invoice_cancel_token: CancellationToken,
wait_invoice_is_active: Arc<AtomicBool>,
kv_store: DynKVStore,
Expand All @@ -71,11 +72,13 @@ impl Cln {
pub async fn new(
rpc_socket: PathBuf,
fee_reserve: FeeReserve,
expose_private_channels: bool,
kv_store: DynKVStore,
) -> Result<Self, Error> {
Ok(Self {
rpc_socket,
fee_reserve,
expose_private_channels,
wait_invoice_cancel_token: CancellationToken::new(),
wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
kv_store,
Expand Down Expand Up @@ -582,20 +585,31 @@ impl MintPayment for Cln {
let amount_msat =
AmountOrAny::Amount(CLN_Amount::from_msat(amount_converted.value()));

let invoice_response = cln_client
.call_typed(&InvoiceRequest {
amount_msat,
description: description.unwrap_or_default(),
label: label.clone(),
expiry: unix_expiry.map(|t| t - time_now),
fallbacks: None,
preimage: None,
cltv: None,
deschashonly: None,
exposeprivatechannels: None,
})
.await
.map_err(Error::from)?;
let request = InvoiceRequest {
amount_msat,
description: description.unwrap_or_default(),
label: label.clone(),
expiry: unix_expiry.map(|t| t - time_now),
fallbacks: None,
preimage: None,
cltv: None,
deschashonly: None,
exposeprivatechannels: None,
};

// cln-rpc types exposeprivatechannels as Option<Vec<ShortChannelId>>
// which cannot represent boolean true. Use call_raw to bypass this
// limitation when expose_private_channels is enabled.
let invoice_response: InvoiceResponse = if self.expose_private_channels {
let mut params = serde_json::to_value(&request).map_err(Error::from)?;
params["exposeprivatechannels"] = serde_json::Value::Bool(true);
cln_client
.call_raw("invoice", &params)
.await
.map_err(Error::from)?
} else {
cln_client.call_typed(&request).await.map_err(Error::from)?
};
Comment on lines +596 to +612
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would another option be on the start up of the mint list the cln channels and then pass in the channel ides on the exposeprivatechannels field of the request? I'm not necessarily requesting we do that instead this is probably simpler just trying to understand the api.

Copy link
Copy Markdown
Contributor Author

@4xvgal 4xvgal Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be cleaner from a cdk<-> cln API perspective. however, fetching short cheannel ids only at start up wouldn't detect channels opened after launch.

The root cause is that cln-rpc doesn't expose the needed fields in its tyepd API yet. Once the upstream issue is resolved, we won't need the startup-based approach or the raw JSON workaround in this PR.

For now, I think it makes sense to go with json-raw approach and switch over when the typed API catches up.


let request = Bolt11Invoice::from_str(&invoice_response.bolt11)?;
let expiry = request.expires_at().map(|t| t.as_secs());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ async fn start_cln_mint(
let cln_config = cdk_mintd::config::Cln {
rpc_path: cln_rpc_path,
bolt12: false,
expose_private_channels: false,
fee_percent: 0.0,
reserve_fee_min: 0.into(),
};
Expand Down
9 changes: 8 additions & 1 deletion crates/cdk-integration-tests/src/init_regtest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ pub fn generate_block(bitcoin_client: &BitcoinClient) -> Result<()> {
}

pub async fn create_cln_backend(cln_client: &ClnClient) -> Result<CdkCln> {
create_cln_backend_with_options(cln_client, false).await
}

pub async fn create_cln_backend_with_options(
cln_client: &ClnClient,
expose_private_channels: bool,
) -> Result<CdkCln> {
let rpc_path = cln_client.rpc_path.clone();

let fee_reserve = FeeReserve {
Expand All @@ -169,7 +176,7 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result<CdkCln> {
};

let kv_store: DynKVStore = Arc::new(memory::empty().await?);
Ok(CdkCln::new(rpc_path, fee_reserve, kv_store).await?)
Ok(CdkCln::new(rpc_path, fee_reserve, expose_private_channels, kv_store).await?)
}

pub async fn create_lnd_backend(lnd_client: &LndClient) -> Result<CdkLnd> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ pub struct ClnClient {
}

impl ClnClient {
/// Open a private (unannounced) channel to a peer
pub async fn open_private_channel(
&self,
amount_sat: u64,
peer_id: &str,
push_amount: Option<u64>,
) -> Result<()> {
let cln_response = self
.client
.lock()
.await
.call(cln_rpc::Request::FundChannel(FundchannelRequest {
amount: AmountOrAll::Amount(Amount::from_sat(amount_sat)),
id: PublicKey::from_str(peer_id)?,
push_msat: push_amount.map(Amount::from_sat),
announce: Some(false),
close_to: None,
compact_lease: None,
feerate: None,
minconf: None,
mindepth: None,
request_amt: None,
reserve: None,
channel_type: None,
utxos: None,
}))
.await?;

let channel_id = match cln_response {
cln_rpc::Response::FundChannel(addr_res) => addr_res.channel_id,
_ => bail!("CLN returned wrong response kind"),
};

tracing::info!("CLN opened private channel: {}", channel_id);

Ok(())
}

/// Create rpc client
pub async fn new(data_dir: PathBuf, rpc_path: Option<PathBuf>) -> Result<Self> {
let rpc_path = rpc_path.unwrap_or(data_dir.join("regtest/lightning-rpc"));
Expand Down
120 changes: 120 additions & 0 deletions crates/cdk-integration-tests/tests/test_expose_private_channels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Test for CLN expose_private_channels feature
//!
//! Verifies that when expose_private_channels is enabled, bolt11 invoices
//! include route hints for private (unannounced) channels.
//!
//! Topology:
//! CLN-1 has public channels (to LND-1, LND-2) and a private channel (to CLN-2).
//! With expose_private_channels=true, private channels become route hint
//! candidates. CLN selects among all candidates per invoice, so the private
//! channel may not appear in every invoice but must appear in at least one.
//!
//! Requires regtest environment with CLN nodes running.

use std::str::FromStr;

use anyhow::Result;
use cashu::Bolt11Invoice;
use cdk::nuts::CurrencyUnit;
use cdk_common::payment::{Bolt11IncomingPaymentOptions, IncomingPaymentOptions, MintPayment};
use cdk_integration_tests::init_regtest::{
create_cln_backend_with_options, generate_block, get_cln_dir, init_bitcoin_client,
};
use cdk_integration_tests::ln_regtest::ln_client::{ClnClient, LightningClient};

fn get_work_dir() -> std::path::PathBuf {
match std::env::var("CDK_ITESTS_DIR") {
Ok(dir) => std::path::PathBuf::from(dir),
Err(_) => panic!("CDK_ITESTS_DIR not set"),
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_expose_private_channels() -> Result<()> {
// Skip if not in regtest environment
if std::env::var("CDK_TEST_REGTEST").is_err() {
return Ok(());
}

let work_dir = get_work_dir();

// Connect to CLN-1 and CLN-2
let cln_one_dir = get_cln_dir(&work_dir, "one");
let cln_two_dir = get_cln_dir(&work_dir, "two");

let cln_one = ClnClient::new(cln_one_dir, None).await?;
let cln_two = ClnClient::new(cln_two_dir, None).await?;

// Open a private channel from CLN-1 to CLN-2
let cln_two_info = cln_two.get_connect_info().await?;
cln_one
.connect_peer(
cln_two_info.pubkey.clone(),
cln_two_info.address.clone(),
cln_two_info.port,
)
.await
.ok(); // May already be connected

// Only open private channel if it doesn't already exist
if let Err(e) = cln_one
.open_private_channel(100_000, &cln_two_info.pubkey, Some(50_000))
.await
{
println!("Private channel already exists or could not be opened: {e}");
} else {
// Mine blocks to confirm the new channel
let bitcoin_client = init_bitcoin_client()?;
generate_block(&bitcoin_client)?;
}

// Wait for channels to be active
cln_one.wait_channels_active().await?;

// Create backend with expose_private_channels = true
let cln_backend = create_cln_backend_with_options(&cln_one, true).await?;

let max_attempts = 100;
let mut found = false;

for i in 0..max_attempts {
let amount = cdk_common::amount::Amount::new(10_000, CurrencyUnit::Msat);
let response = cln_backend
.create_incoming_payment_request(IncomingPaymentOptions::Bolt11(
Bolt11IncomingPaymentOptions {
amount,
description: Some(format!("test exposed {i}")),
unix_expiry: None,
},
))
.await?;

let invoice = Bolt11Invoice::from_str(&response.request)?;
let hints = invoice.route_hints();

let has_private_channel_hint = hints.iter().any(|hint| {
hint.0
.iter()
.any(|hop| hop.src_node_id.to_string() == cln_two_info.pubkey)
});

println!(
"Invoice {i}: private_channel_hint={has_private_channel_hint}, total_hints={}",
hints.len()
);

if has_private_channel_hint {
println!("Private channel hint found on attempt {i}");
found = true;
break;
}
}

assert!(
found,
"None of {max_attempts} invoices included the private channel route hint. \
expose_private_channels=true should make private channels route hint candidates."
);

Ok(())
}
1 change: 1 addition & 0 deletions crates/cdk-mintd/example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ ln_backend = "fakewallet"
# [cln]
# rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc"
# bolt12 = true # Optional, defaults to true
# expose_private_channels = false # Optional, defaults to false. Include private channel route hints in bolt11 invoices.
# fee_percent = 0.02 # Optional, defaults to 2%
# reserve_fee_min = 2 # Optional, defaults to 2 sats

Expand Down
3 changes: 3 additions & 0 deletions crates/cdk-mintd/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ pub struct Cln {
pub rpc_path: PathBuf,
#[serde(default = "default_cln_bolt12")]
pub bolt12: bool,
#[serde(default)]
pub expose_private_channels: bool,
#[serde(default = "default_fee_percent")]
pub fee_percent: f32,
#[serde(default = "default_reserve_fee_min")]
Expand All @@ -245,6 +247,7 @@ impl Default for Cln {
Self {
rpc_path: PathBuf::new(),
bolt12: true,
expose_private_channels: false,
fee_percent: 0.02,
reserve_fee_min: 2.into(),
}
Expand Down
8 changes: 8 additions & 0 deletions crates/cdk-mintd/src/env_vars/cln.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub const ENV_CLN_RPC_PATH: &str = "CDK_MINTD_CLN_RPC_PATH";
pub const ENV_CLN_BOLT12: &str = "CDK_MINTD_CLN_BOLT12";
pub const ENV_CLN_FEE_PERCENT: &str = "CDK_MINTD_CLN_FEE_PERCENT";
pub const ENV_CLN_RESERVE_FEE_MIN: &str = "CDK_MINTD_CLN_RESERVE_FEE_MIN";
pub const ENV_CLN_EXPOSE_PRIVATE_CHANNELS: &str = "CDK_MINTD_CLN_EXPOSE_PRIVATE_CHANNELS";

impl Cln {
pub fn from_env(mut self) -> Self {
Expand All @@ -25,6 +26,13 @@ impl Cln {
}
}

// Expose private channels
if let Ok(expose_str) = env::var(ENV_CLN_EXPOSE_PRIVATE_CHANNELS) {
if let Ok(expose) = expose_str.parse() {
self.expose_private_channels = expose;
}
}

// Fee percent
if let Ok(fee_str) = env::var(ENV_CLN_FEE_PERCENT) {
if let Ok(fee) = fee_str.parse() {
Expand Down
1 change: 1 addition & 0 deletions crates/cdk-mintd/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ impl LnBackendSetup for config::Cln {
let cln = cdk_cln::Cln::new(
cln_socket,
fee_reserve,
self.expose_private_channels,
kv_store.expect("Cln needs kv store"),
)
.await?;
Expand Down
5 changes: 4 additions & 1 deletion crates/cdk-payment-processor/src/bin/payment_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ async fn main() -> anyhow::Result<()> {
};

let kv_store = Arc::new(MintSqliteDatabase::new(":memory:").await?);
Arc::new(cdk_cln::Cln::new(cln_settings.rpc_path, fee_reserve, kv_store).await?)
Arc::new(
cdk_cln::Cln::new(cln_settings.rpc_path, fee_reserve, false, kv_store)
.await?,
)
}
#[cfg(feature = "fake")]
"FAKEWALLET" => {
Expand Down
Loading