Skip to content
Open
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
69 changes: 69 additions & 0 deletions tests/common/cln.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,41 @@ impl ExternalNode for TestClnNode {
Ok(invoice.bolt11)
}

async fn create_offer(
&self, amount_msat: u64, description: &str,
) -> Result<String, TestFailure> {
let desc = description.to_string();
let label = format!(
"{}-{}",
desc,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);

let mut params = serde_json::json!({
"amount": format!("{}msat", amount_msat),
"description": desc,
"label": label,
"single_use": true,
});

let response: serde_json::Value = self
.rpc(move |c| c.call("offer", params))
.await
.map_err(|e| self.make_error(format!("offer RPC call failed: {}", e)))?;

let offer_str = response["bolt12"]
.as_str()
.ok_or_else(|| {
self.make_error("Failed to parse 'bolt12' from CLN response".to_string())
})?
.to_string();

Ok(offer_str)
}

async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
let inv = invoice.to_string();
let result = self
Expand All @@ -190,6 +225,40 @@ impl ExternalNode for TestClnNode {
Ok(result.payment_preimage)
}

async fn pay_offer(
&self, offer_str: &str, amount_msat: Option<u64>,
) -> Result<String, TestFailure> {
let offer = offer_str.to_string();

let mut fetch_params = serde_json::json!({
"offer": offer,
"quantity": 1,
});

if let Some(msat) = amount_msat {
fetch_params["amount_msat"] = serde_json::json!(format!("{}msat", msat));
}

let fetch_response: serde_json::Value =
self.rpc(move |c| c.call("fetchinvoice", fetch_params)).await.map_err(|e| {
self.make_error(format!("fetchinvoice RPC call failed for BOLT12: {}", e))
})?;

let inv = fetch_response["invoice"]
.as_str()
.ok_or_else(|| {
self.make_error("Failed to parse 'invoice' from fetchinvoice response".to_string())
})?
.to_string();

let result = self
.rpc(move |c| c.pay(&inv, PayOptions::default()))
.await
.map_err(|e| self.make_error(format!("pay: {}", e)))?;

Ok(result.payment_preimage)
}

async fn send_keysend(
&self, peer_id: PublicKey, amount_msat: u64,
) -> Result<String, TestFailure> {
Expand Down
12 changes: 12 additions & 0 deletions tests/common/eclair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ impl ExternalNode for TestEclairNode {
Ok(invoice.to_string())
}

async fn create_offer(
&self, amount_msat: u64, description: &str,
) -> Result<String, TestFailure> {
Err(self.make_error("create_offer is not supported on Eclair".to_string()))
}

async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
let result = self.post("/payinvoice", &[("invoice", invoice)]).await?;
let payment_id = result
Expand All @@ -220,6 +226,12 @@ impl ExternalNode for TestEclairNode {
self.poll_payment_settlement(&payment_id, "payment").await
}

async fn pay_offer(
&self, _offer_str: &str, _amount_msat: Option<u64>,
) -> Result<String, TestFailure> {
Err(self.make_error("pay_offer is not supported on Eclair".to_string()))
}

async fn send_keysend(
&self, peer_id: PublicKey, amount_msat: u64,
) -> Result<String, TestFailure> {
Expand Down
10 changes: 10 additions & 0 deletions tests/common/external_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,19 @@ pub(crate) trait ExternalNode: Send + Sync {
&self, amount_msat: u64, description: &str,
) -> Result<String, TestFailure>;

/// Create a BOLT12 offer for the given amount
async fn create_offer(
&self, amount_msat: u64, description: &str,
) -> Result<String, TestFailure>;

/// Pay a BOLT11 invoice; returns an implementation-specific payment identifier on success.
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure>;

/// Pay a BOLT12 offer; returns an implementation-specific payment identifier on success.
async fn pay_offer(
&self, offer_str: &str, amount_msat: Option<u64>,
) -> Result<String, TestFailure>;

/// Send a keysend payment to a peer.
async fn send_keysend(
&self, peer_id: PublicKey, amount_msat: u64,
Expand Down
15 changes: 15 additions & 0 deletions tests/common/lnd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ impl ExternalNode for TestLndNode {
Ok(response.payment_request)
}

async fn create_offer(
&self, amount_msat: u64, description: &str,
) -> Result<String, TestFailure> {
Err(self.make_error(
"create_offer is not supported on LND without LNDK integration".to_string(),
))
}

async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
let mut client = self.client.lock().await;
let request = SendPaymentRequest {
Expand Down Expand Up @@ -280,6 +288,13 @@ impl ExternalNode for TestLndNode {
Err(self.make_error("payment stream ended without terminal status"))
}

async fn pay_offer(
&self, _offer_str: &str, _amount_msat: Option<u64>,
) -> Result<String, TestFailure> {
Err(self
.make_error("pay_offer is not supported on LND without LNDK integration".to_string()))
}

async fn send_keysend(
&self, peer_id: PublicKey, amount_msat: u64,
) -> Result<String, TestFailure> {
Expand Down
24 changes: 22 additions & 2 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,30 @@ macro_rules! expect_payment_received_event {
panic!("{} timed out waiting for PaymentReceived event after 60s", $node.node_id())
});
match event {
ref e @ Event::PaymentReceived { payment_id, amount_msat, .. } => {
ref e @ Event::PaymentReceived { payment_id, amount_msat: rec_msat, .. } => {
println!("{} got event {:?}", $node.node_id(), e);
assert_eq!(amount_msat, $amount_msat);

let payment = $node.payment(&payment_id.unwrap()).unwrap();

match payment.kind {
ldk_node::payment::PaymentKind::Bolt12Offer { .. } => {
// BOLT12: Blinded paths can lead to minor overpayments (e.g., routing path fees)
assert!(
rec_msat >= $amount_msat,
"BOLT12: Received amount ({}) is less than expected ({})",
rec_msat,
$amount_msat
);
},
_ => {
assert_eq!(
rec_msat, $amount_msat,
"BOLT11/Keysend: Received amount ({}) does not match expected ({})",
rec_msat, $amount_msat
);
},
}

if !matches!(payment.kind, ldk_node::payment::PaymentKind::Onchain { .. }) {
assert_eq!(payment.fee_paid_msat, None);
}
Expand Down
125 changes: 117 additions & 8 deletions tests/common/scenarios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use std::time::Duration;
use bitcoin::Amount;
use electrsd::corepc_node::Client as BitcoindClient;
use electrum_client::ElectrumApi;
use ldk_node::bitcoin::secp256k1::PublicKey;
use ldk_node::{Event, Node};

use super::external_node::ExternalNode;
Expand Down Expand Up @@ -87,6 +88,29 @@ pub(crate) async fn wait_for_htlcs_settled(
panic!("HTLCs did not settle on {} channel {} within 15s", peer.name(), ext_channel_id);
}

/// Blocks execution until the channel with the specified peer becomes active (`is_usable == true`).
/// Maximum wait time is 15 seconds (30 attempts with a 500ms interval).
pub(crate) async fn wait_for_channel_usable(node: &Node, counterparty_node_id: PublicKey) {
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.

We shouldn't use this manual sleep, we have events for that.

let mut usable = false;

for _ in 0..30 {
for channel in node.list_channels() {
if channel.counterparty_node_id == counterparty_node_id && channel.is_usable {
usable = true;
break;
}
}

if usable {
break;
}

tokio::time::sleep(Duration::from_millis(500)).await;
}

assert!(usable, "Timeout waiting for channel with {} to become usable", counterparty_node_id);
}

/// Build a fresh LDK node configured for interop tests. Uses electrum at the
/// docker-compose default port and bumps sync timeouts for combo stress.
pub(crate) fn setup_ldk_node() -> Node {
Expand Down Expand Up @@ -150,9 +174,15 @@ pub(crate) async fn run_interop_scenario<N, E, F>(
node.stop().unwrap();
}

/// Open a channel, send a BOLT11 payment in each direction, then cooperatively close.
pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
enum PaymentProtocol {
Bolt11,
Bolt12,
}

/// Open a channel, send a BOLT11/BOLT12 payment in each direction, then cooperatively close.
async fn basic_channel_cycle_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
protocol: PaymentProtocol,
) {
let (user_ch, ext_ch) = channel::open_channel_to_external(
node,
Expand All @@ -164,12 +194,36 @@ pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
)
.await;

payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await;
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
match protocol {
PaymentProtocol::Bolt11 => {
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send-bolt11").await;
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
},
PaymentProtocol::Bolt12 => {
payment::send_bolt12_to_peer(node, peer, 10_000_000, "basic-send-bolt12").await;
payment::receive_bolt12_payment(node, peer, 10_000_000).await;
},
}

channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
}

/// Specialized version of `basic_channel_cycle_scenario` for BOLT11 payments.
/// See [`basic_channel_cycle_scenario`] for details.
pub(crate) async fn basic_channel_cycle_bolt11_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
basic_channel_cycle_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11).await;
}

/// Specialized version of `basic_channel_cycle_scenario` for BOLT12 payments.
/// See [`basic_channel_cycle_scenario`] for details.
pub(crate) async fn basic_channel_cycle_bolt12_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
basic_channel_cycle_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12).await;
}

/// Open a channel, send keysend in both directions, then cooperatively close.
pub(crate) async fn keysend_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
Expand All @@ -189,8 +243,9 @@ pub(crate) async fn keysend_scenario<E: ElectrumApi>(
}

/// Open a channel, send a payment, then force-close from the LDK side.
pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
async fn force_close_after_payment_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
protocol: PaymentProtocol,
) {
let (user_ch, ext_ch) = channel::open_channel_to_external(
node,
Expand All @@ -201,11 +256,38 @@ pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
Some(500_000_000),
)
.await;
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close").await;

match protocol {
PaymentProtocol::Bolt11 => {
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close-bolt11").await;
},
PaymentProtocol::Bolt12 => {
payment::send_bolt12_to_peer(node, peer, 5_000_000, "force-close-bolt12").await;
},
}

wait_for_htlcs_settled(peer, &ext_ch).await;
channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
}

/// Specialized version of `force_close_after_payment_scenario` for BOLT11 payments.
/// See [`force_close_after_payment_scenario`] for details.
pub(crate) async fn force_close_after_payment_bolt11_scenario<E: ElectrumApi>(
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.

I don't understand why we need all this additional boilerplate.

node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11)
.await;
}

/// Specialized version of `force_close_after_payment_scenario` for BOLT12 payments.
/// See [`force_close_after_payment_scenario`] for details.
pub(crate) async fn force_close_after_payment_bolt12_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12)
.await;
}

/// Open a channel, dispatch a payment with a mid-flight disconnect+reconnect,
/// then cooperatively close.
pub(crate) async fn disconnect_during_payment_scenario<E: ElectrumApi>(
Expand All @@ -226,8 +308,9 @@ pub(crate) async fn disconnect_during_payment_scenario<E: ElectrumApi>(
}

/// Open a channel, splice-in additional funds, send a post-splice payment, then close.
pub(crate) async fn splice_in_scenario<E: ElectrumApi>(
async fn splice_in_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
protocol: PaymentProtocol,
) {
let (user_ch, ext_ch) = channel::open_channel_to_external(
node,
Expand All @@ -245,7 +328,33 @@ pub(crate) async fn splice_in_scenario<E: ElectrumApi>(
sync_wallets_with_retry(node).await;
expect_channel_ready_event!(node, ext_node_id);

payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice").await;
match protocol {
PaymentProtocol::Bolt11 => {
payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice-bolt11").await;
},
PaymentProtocol::Bolt12 => {
// Wait for Onion Message router updates the channel to usable state.
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.

I don't think I understand what "Wait for Onion Message router updates the channel to usable state." means. Can you expand?

// Without this, Bolt12 pathfinding will fail even though the channel technically fired `ChannelReady`.
wait_for_channel_usable(node, ext_node_id).await;
payment::send_bolt12_to_peer(node, peer, 5_000_000, "post-splice-bolt12").await;
},
}

channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
}

/// Specialized version of `splice_in_scenario` for BOLT11 payments.
/// See [`splice_in_scenario`] for details.
pub(crate) async fn splice_in_bolt11_scenario<E: ElectrumApi>(
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.

Please avoid adding helper methods that are less than 5 lines of code.

node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
splice_in_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11).await;
}

/// Specialized version of `splice_in_scenario` for BOLT12 payments.
/// See [`splice_in_scenario`] for details.
pub(crate) async fn splice_in_bolt12_scenario<E: ElectrumApi>(
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
) {
splice_in_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12).await;
}
Loading
Loading