Skip to content

Commit bd238f7

Browse files
committed
tests: Add interoperability tests for BOLT12 (Offers) with CLN
Activates scenarios for BOLT12 offer payments between ldk-node and Core Lightning (CLN) inside integration tests Fix #856
1 parent 109978d commit bd238f7

10 files changed

Lines changed: 356 additions & 38 deletions

File tree

tests/common/cln.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,41 @@ impl ExternalNode for TestClnNode {
181181
Ok(invoice.bolt11)
182182
}
183183

184+
async fn create_offer(
185+
&self, amount_msat: u64, description: &str,
186+
) -> Result<String, TestFailure> {
187+
let desc = description.to_string();
188+
let label = format!(
189+
"{}-{}",
190+
desc,
191+
std::time::SystemTime::now()
192+
.duration_since(std::time::UNIX_EPOCH)
193+
.unwrap_or_default()
194+
.as_nanos()
195+
);
196+
197+
let mut params = serde_json::json!({
198+
"amount": format!("{}msat", amount_msat),
199+
"description": desc,
200+
"label": label,
201+
"single_use": true,
202+
});
203+
204+
let response: serde_json::Value = self
205+
.rpc(move |c| c.call("offer", params))
206+
.await
207+
.map_err(|e| self.make_error(format!("offer RPC call failed: {}", e)))?;
208+
209+
let offer_str = response["bolt12"]
210+
.as_str()
211+
.ok_or_else(|| {
212+
self.make_error("Failed to parse 'bolt12' from CLN response".to_string())
213+
})?
214+
.to_string();
215+
216+
Ok(offer_str)
217+
}
218+
184219
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
185220
let inv = invoice.to_string();
186221
let result = self
@@ -190,6 +225,40 @@ impl ExternalNode for TestClnNode {
190225
Ok(result.payment_preimage)
191226
}
192227

228+
async fn pay_offer(
229+
&self, offer_str: &str, amount_msat: Option<u64>,
230+
) -> Result<String, TestFailure> {
231+
let offer = offer_str.to_string();
232+
233+
let mut fetch_params = serde_json::json!({
234+
"offer": offer,
235+
"quantity": 1,
236+
});
237+
238+
if let Some(msat) = amount_msat {
239+
fetch_params["amount_msat"] = serde_json::json!(format!("{}msat", msat));
240+
}
241+
242+
let fetch_response: serde_json::Value =
243+
self.rpc(move |c| c.call("fetchinvoice", fetch_params)).await.map_err(|e| {
244+
self.make_error(format!("fetchinvoice RPC call failed for BOLT12: {}", e))
245+
})?;
246+
247+
let inv = fetch_response["invoice"]
248+
.as_str()
249+
.ok_or_else(|| {
250+
self.make_error("Failed to parse 'invoice' from fetchinvoice response".to_string())
251+
})?
252+
.to_string();
253+
254+
let result = self
255+
.rpc(move |c| c.pay(&inv, PayOptions::default()))
256+
.await
257+
.map_err(|e| self.make_error(format!("pay: {}", e)))?;
258+
259+
Ok(result.payment_preimage)
260+
}
261+
193262
async fn send_keysend(
194263
&self, peer_id: PublicKey, amount_msat: u64,
195264
) -> Result<String, TestFailure> {

tests/common/eclair.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ impl ExternalNode for TestEclairNode {
211211
Ok(invoice.to_string())
212212
}
213213

214+
async fn create_offer(
215+
&self, amount_msat: u64, description: &str,
216+
) -> Result<String, TestFailure> {
217+
Err(self.make_error("create_offer is not supported on Eclair".to_string()))
218+
}
219+
214220
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
215221
let result = self.post("/payinvoice", &[("invoice", invoice)]).await?;
216222
let payment_id = result
@@ -220,6 +226,12 @@ impl ExternalNode for TestEclairNode {
220226
self.poll_payment_settlement(&payment_id, "payment").await
221227
}
222228

229+
async fn pay_offer(
230+
&self, _offer_str: &str, _amount_msat: Option<u64>,
231+
) -> Result<String, TestFailure> {
232+
Err(self.make_error("pay_offer is not supported on Eclair".to_string()))
233+
}
234+
223235
async fn send_keysend(
224236
&self, peer_id: PublicKey, amount_msat: u64,
225237
) -> Result<String, TestFailure> {

tests/common/external_node.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,19 @@ pub(crate) trait ExternalNode: Send + Sync {
8787
&self, amount_msat: u64, description: &str,
8888
) -> Result<String, TestFailure>;
8989

90+
/// Create a BOLT12 offer for the given amount
91+
async fn create_offer(
92+
&self, amount_msat: u64, description: &str,
93+
) -> Result<String, TestFailure>;
94+
9095
/// Pay a BOLT11 invoice; returns an implementation-specific payment identifier on success.
9196
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure>;
9297

98+
/// Pay a BOLT12 offer; returns an implementation-specific payment identifier on success.
99+
async fn pay_offer(
100+
&self, offer_str: &str, amount_msat: Option<u64>,
101+
) -> Result<String, TestFailure>;
102+
93103
/// Send a keysend payment to a peer.
94104
async fn send_keysend(
95105
&self, peer_id: PublicKey, amount_msat: u64,

tests/common/lnd.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,14 @@ impl ExternalNode for TestLndNode {
243243
Ok(response.payment_request)
244244
}
245245

246+
async fn create_offer(
247+
&self, amount_msat: u64, description: &str,
248+
) -> Result<String, TestFailure> {
249+
Err(self.make_error(
250+
"create_offer is not supported on LND without LNDK integration".to_string(),
251+
))
252+
}
253+
246254
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
247255
let mut client = self.client.lock().await;
248256
let request = SendPaymentRequest {
@@ -280,6 +288,13 @@ impl ExternalNode for TestLndNode {
280288
Err(self.make_error("payment stream ended without terminal status"))
281289
}
282290

291+
async fn pay_offer(
292+
&self, _offer_str: &str, _amount_msat: Option<u64>,
293+
) -> Result<String, TestFailure> {
294+
Err(self
295+
.make_error("pay_offer is not supported on LND without LNDK integration".to_string()))
296+
}
297+
283298
async fn send_keysend(
284299
&self, peer_id: PublicKey, amount_msat: u64,
285300
) -> Result<String, TestFailure> {

tests/common/mod.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,30 @@ macro_rules! expect_payment_received_event {
216216
panic!("{} timed out waiting for PaymentReceived event after 60s", $node.node_id())
217217
});
218218
match event {
219-
ref e @ Event::PaymentReceived { payment_id, amount_msat, .. } => {
219+
ref e @ Event::PaymentReceived { payment_id, amount_msat: rec_msat, .. } => {
220220
println!("{} got event {:?}", $node.node_id(), e);
221-
assert_eq!(amount_msat, $amount_msat);
221+
222222
let payment = $node.payment(&payment_id.unwrap()).unwrap();
223+
224+
match payment.kind {
225+
ldk_node::payment::PaymentKind::Bolt12Offer { .. } => {
226+
// BOLT12: Blinded paths can lead to minor overpayments (e.g., routing path fees)
227+
assert!(
228+
rec_msat >= $amount_msat,
229+
"BOLT12: Received amount ({}) is less than expected ({})",
230+
rec_msat,
231+
$amount_msat
232+
);
233+
},
234+
_ => {
235+
assert_eq!(
236+
rec_msat, $amount_msat,
237+
"BOLT11/Keysend: Received amount ({}) does not match expected ({})",
238+
rec_msat, $amount_msat
239+
);
240+
},
241+
}
242+
223243
if !matches!(payment.kind, ldk_node::payment::PaymentKind::Onchain { .. }) {
224244
assert_eq!(payment.fee_paid_msat, None);
225245
}

tests/common/scenarios/mod.rs

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use std::time::Duration;
2020
use bitcoin::Amount;
2121
use electrsd::corepc_node::Client as BitcoindClient;
2222
use electrum_client::ElectrumApi;
23+
use ldk_node::bitcoin::secp256k1::PublicKey;
2324
use ldk_node::{Event, Node};
2425

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

91+
/// Blocks execution until the channel with the specified peer becomes active (`is_usable == true`).
92+
/// Maximum wait time is 15 seconds (30 attempts with a 500ms interval).
93+
pub(crate) async fn wait_for_channel_usable(node: &Node, counterparty_node_id: PublicKey) {
94+
let mut usable = false;
95+
96+
for _ in 0..30 {
97+
for channel in node.list_channels() {
98+
if channel.counterparty_node_id == counterparty_node_id && channel.is_usable {
99+
usable = true;
100+
break;
101+
}
102+
}
103+
104+
if usable {
105+
break;
106+
}
107+
108+
tokio::time::sleep(Duration::from_millis(500)).await;
109+
}
110+
111+
assert!(usable, "Timeout waiting for channel with {} to become usable", counterparty_node_id);
112+
}
113+
90114
/// Build a fresh LDK node configured for interop tests. Uses electrum at the
91115
/// docker-compose default port and bumps sync timeouts for combo stress.
92116
pub(crate) fn setup_ldk_node() -> Node {
@@ -150,9 +174,15 @@ pub(crate) async fn run_interop_scenario<N, E, F>(
150174
node.stop().unwrap();
151175
}
152176

153-
/// Open a channel, send a BOLT11 payment in each direction, then cooperatively close.
154-
pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
177+
enum PaymentProtocol {
178+
Bolt11,
179+
Bolt12,
180+
}
181+
182+
/// Open a channel, send a BOLT11/BOLT12 payment in each direction, then cooperatively close.
183+
async fn basic_channel_cycle_scenario<E: ElectrumApi>(
155184
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
185+
protocol: PaymentProtocol,
156186
) {
157187
let (user_ch, ext_ch) = channel::open_channel_to_external(
158188
node,
@@ -164,12 +194,36 @@ pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
164194
)
165195
.await;
166196

167-
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await;
168-
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
197+
match protocol {
198+
PaymentProtocol::Bolt11 => {
199+
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send-bolt11").await;
200+
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
201+
},
202+
PaymentProtocol::Bolt12 => {
203+
payment::send_bolt12_to_peer(node, peer, 10_000_000, "basic-send-bolt12").await;
204+
payment::receive_bolt12_payment(node, peer, 10_000_000).await;
205+
},
206+
}
169207

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

211+
/// Specialized version of `basic_channel_cycle_scenario` for BOLT11 payments.
212+
/// See [`basic_channel_cycle_scenario`] for details.
213+
pub(crate) async fn basic_channel_cycle_bolt11_scenario<E: ElectrumApi>(
214+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
215+
) {
216+
basic_channel_cycle_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11).await;
217+
}
218+
219+
/// Specialized version of `basic_channel_cycle_scenario` for BOLT12 payments.
220+
/// See [`basic_channel_cycle_scenario`] for details.
221+
pub(crate) async fn basic_channel_cycle_bolt12_scenario<E: ElectrumApi>(
222+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
223+
) {
224+
basic_channel_cycle_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12).await;
225+
}
226+
173227
/// Open a channel, send keysend in both directions, then cooperatively close.
174228
pub(crate) async fn keysend_scenario<E: ElectrumApi>(
175229
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
@@ -189,8 +243,9 @@ pub(crate) async fn keysend_scenario<E: ElectrumApi>(
189243
}
190244

191245
/// Open a channel, send a payment, then force-close from the LDK side.
192-
pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
246+
async fn force_close_after_payment_scenario<E: ElectrumApi>(
193247
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
248+
protocol: PaymentProtocol,
194249
) {
195250
let (user_ch, ext_ch) = channel::open_channel_to_external(
196251
node,
@@ -201,11 +256,38 @@ pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
201256
Some(500_000_000),
202257
)
203258
.await;
204-
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close").await;
259+
260+
match protocol {
261+
PaymentProtocol::Bolt11 => {
262+
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close-bolt11").await;
263+
},
264+
PaymentProtocol::Bolt12 => {
265+
payment::send_bolt12_to_peer(node, peer, 5_000_000, "force-close-bolt12").await;
266+
},
267+
}
268+
205269
wait_for_htlcs_settled(peer, &ext_ch).await;
206270
channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
207271
}
208272

273+
/// Specialized version of `force_close_after_payment_scenario` for BOLT11 payments.
274+
/// See [`force_close_after_payment_scenario`] for details.
275+
pub(crate) async fn force_close_after_payment_bolt11_scenario<E: ElectrumApi>(
276+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
277+
) {
278+
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11)
279+
.await;
280+
}
281+
282+
/// Specialized version of `force_close_after_payment_scenario` for BOLT12 payments.
283+
/// See [`force_close_after_payment_scenario`] for details.
284+
pub(crate) async fn force_close_after_payment_bolt12_scenario<E: ElectrumApi>(
285+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
286+
) {
287+
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12)
288+
.await;
289+
}
290+
209291
/// Open a channel, dispatch a payment with a mid-flight disconnect+reconnect,
210292
/// then cooperatively close.
211293
pub(crate) async fn disconnect_during_payment_scenario<E: ElectrumApi>(
@@ -226,8 +308,9 @@ pub(crate) async fn disconnect_during_payment_scenario<E: ElectrumApi>(
226308
}
227309

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

248-
payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice").await;
331+
match protocol {
332+
PaymentProtocol::Bolt11 => {
333+
payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice-bolt11").await;
334+
},
335+
PaymentProtocol::Bolt12 => {
336+
// Wait for Onion Message router updates the channel to usable state.
337+
// Without this, Bolt12 pathfinding will fail even though the channel technically fired `ChannelReady`.
338+
wait_for_channel_usable(node, ext_node_id).await;
339+
payment::send_bolt12_to_peer(node, peer, 5_000_000, "post-splice-bolt12").await;
340+
},
341+
}
249342

250343
channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
251344
}
345+
346+
/// Specialized version of `splice_in_scenario` for BOLT11 payments.
347+
/// See [`splice_in_scenario`] for details.
348+
pub(crate) async fn splice_in_bolt11_scenario<E: ElectrumApi>(
349+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
350+
) {
351+
splice_in_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11).await;
352+
}
353+
354+
/// Specialized version of `splice_in_scenario` for BOLT12 payments.
355+
/// See [`splice_in_scenario`] for details.
356+
pub(crate) async fn splice_in_bolt12_scenario<E: ElectrumApi>(
357+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
358+
) {
359+
splice_in_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12).await;
360+
}

0 commit comments

Comments
 (0)