Skip to content

Commit bf29202

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 bf29202

10 files changed

Lines changed: 347 additions & 39 deletions

File tree

tests/common/cln.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,40 @@ 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(|| self.make_error("Failed to parse 'bolt12' from CLN response".to_string()))?
212+
.to_string();
213+
214+
Ok(offer_str)
215+
}
216+
217+
184218
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
185219
let inv = invoice.to_string();
186220
let result = self
@@ -190,6 +224,37 @@ impl ExternalNode for TestClnNode {
190224
Ok(result.payment_preimage)
191225
}
192226

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

tests/common/eclair.rs

Lines changed: 10 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,10 @@ impl ExternalNode for TestEclairNode {
220226
self.poll_payment_settlement(&payment_id, "payment").await
221227
}
222228

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

tests/common/external_node.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,17 @@ 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(&self, offer_str: &str, amount_msat: Option<u64>) -> Result<String, TestFailure>;
100+
93101
/// Send a keysend payment to a peer.
94102
async fn send_keysend(
95103
&self, peer_id: PublicKey, amount_msat: u64,

tests/common/lnd.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ 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("create_offer is not supported on LND without LNDK integration".to_string()))
250+
}
251+
246252
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
247253
let mut client = self.client.lock().await;
248254
let request = SendPaymentRequest {
@@ -280,6 +286,10 @@ impl ExternalNode for TestLndNode {
280286
Err(self.make_error("payment stream ended without terminal status"))
281287
}
282288

289+
async fn pay_offer(&self, _offer_str: &str, _amount_msat: Option<u64>) -> Result<String, TestFailure> {
290+
Err(self.make_error("pay_offer is not supported on LND without LNDK integration".to_string()))
291+
}
292+
283293
async fn send_keysend(
284294
&self, peer_id: PublicKey, amount_msat: u64,
285295
) -> Result<String, TestFailure> {

tests/common/mod.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,27 @@ 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 ({})", rec_msat, $amount_msat
230+
);
231+
},
232+
_ => {
233+
assert_eq!(
234+
rec_msat, $amount_msat,
235+
"BOLT11/Keysend: Received amount ({}) does not match expected ({})", rec_msat, $amount_msat
236+
);
237+
}
238+
}
239+
223240
if !matches!(payment.kind, ldk_node::payment::PaymentKind::Onchain { .. }) {
224241
assert_eq!(payment.fee_paid_msat, None);
225242
}

tests/common/scenarios/mod.rs

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use bitcoin::Amount;
2121
use electrsd::corepc_node::Client as BitcoindClient;
2222
use electrum_client::ElectrumApi;
2323
use ldk_node::{Event, Node};
24+
use ldk_node::bitcoin::secp256k1::PublicKey;
2425

2526
use super::external_node::ExternalNode;
2627
use super::{generate_blocks_and_wait, premine_and_distribute_funds};
@@ -87,6 +88,33 @@ 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!(
112+
usable,
113+
"Timeout waiting for channel with {} to become usable",
114+
counterparty_node_id
115+
);
116+
}
117+
90118
/// Build a fresh LDK node configured for interop tests. Uses electrum at the
91119
/// docker-compose default port and bumps sync timeouts for combo stress.
92120
pub(crate) fn setup_ldk_node() -> Node {
@@ -150,9 +178,14 @@ pub(crate) async fn run_interop_scenario<N, E, F>(
150178
node.stop().unwrap();
151179
}
152180

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>(
155-
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
181+
enum PaymentProtocol {
182+
Bolt11,
183+
Bolt12,
184+
}
185+
186+
/// Open a channel, send a BOLT11/BOLT12 payment in each direction, then cooperatively close.
187+
async fn basic_channel_cycle_scenario<E: ElectrumApi>(
188+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, protocol: PaymentProtocol,
156189
) {
157190
let (user_ch, ext_ch) = channel::open_channel_to_external(
158191
node,
@@ -164,12 +197,36 @@ pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
164197
)
165198
.await;
166199

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;
200+
match protocol {
201+
PaymentProtocol::Bolt11 => {
202+
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send-bolt11").await;
203+
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
204+
}
205+
PaymentProtocol::Bolt12 => {
206+
payment::send_bolt12_to_peer(node, peer, 10_000_000, "basic-send-bolt12").await;
207+
payment::receive_bolt12_payment(node, peer, 10_000_000).await;
208+
}
209+
}
169210

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

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

191248
/// 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>(
193-
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
249+
async fn force_close_after_payment_scenario<E: ElectrumApi>(
250+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, protocol: PaymentProtocol,
194251
) {
195252
let (user_ch, ext_ch) = channel::open_channel_to_external(
196253
node,
@@ -201,11 +258,36 @@ pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
201258
Some(500_000_000),
202259
)
203260
.await;
204-
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close").await;
261+
262+
match protocol {
263+
PaymentProtocol::Bolt11 => {
264+
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close-bolt11").await;
265+
}
266+
PaymentProtocol::Bolt12 => {
267+
payment::send_bolt12_to_peer(node, peer, 5_000_000, "force-close-bolt12").await;
268+
}
269+
}
270+
205271
wait_for_htlcs_settled(peer, &ext_ch).await;
206272
channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
207273
}
208274

275+
/// Specialized version of `force_close_after_payment_scenario` for BOLT11 payments.
276+
/// See [`force_close_after_payment_scenario`] for details.
277+
pub(crate) async fn force_close_after_payment_bolt11_scenario<E: ElectrumApi>(
278+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
279+
) {
280+
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt11).await;
281+
}
282+
283+
/// Specialized version of `force_close_after_payment_scenario` for BOLT12 payments.
284+
/// See [`force_close_after_payment_scenario`] for details.
285+
pub(crate) async fn force_close_after_payment_bolt12_scenario<E: ElectrumApi>(
286+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
287+
) {
288+
force_close_after_payment_scenario(node, peer, bitcoind, electrs, PaymentProtocol::Bolt12).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,8 @@ 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>(
230-
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
311+
async fn splice_in_scenario<E: ElectrumApi>(
312+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, protocol: PaymentProtocol,
231313
) {
232314
let (user_ch, ext_ch) = channel::open_channel_to_external(
233315
node,
@@ -245,7 +327,33 @@ pub(crate) async fn splice_in_scenario<E: ElectrumApi>(
245327
sync_wallets_with_retry(node).await;
246328
expect_channel_ready_event!(node, ext_node_id);
247329

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

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

0 commit comments

Comments
 (0)