Skip to content

Commit bb198e7

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 bb198e7

10 files changed

Lines changed: 315 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 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: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ pub(crate) async fn run_interop_scenario<N, E, F>(
151151
}
152152

153153
/// Open a channel, send a BOLT11 payment in each direction, then cooperatively close.
154-
pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
154+
pub(crate) async fn basic_channel_cycle_bolt11_scenario<E: ElectrumApi>(
155155
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
156156
) {
157157
let (user_ch, ext_ch) = channel::open_channel_to_external(
@@ -164,12 +164,32 @@ pub(crate) async fn basic_channel_cycle_scenario<E: ElectrumApi>(
164164
)
165165
.await;
166166

167-
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await;
167+
payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send-bolt11").await;
168168
payment::receive_bolt11_payment(node, peer, 10_000_000).await;
169169

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

173+
/// Open a channel, send a BOLT12 payment in each direction, then cooperatively close.
174+
pub(crate) async fn basic_channel_cycle_bolt12_scenario<E: ElectrumApi>(
175+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
176+
) {
177+
let (user_ch, ext_ch) = channel::open_channel_to_external(
178+
node,
179+
peer,
180+
bitcoind,
181+
electrs,
182+
1_000_000,
183+
Some(500_000_000),
184+
)
185+
.await;
186+
187+
payment::send_bolt12_to_peer(node, peer, 10_000_000, "basic-send-bolt12").await;
188+
payment::receive_bolt12_payment(node, peer, 10_000_000).await;
189+
190+
channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
191+
}
192+
173193
/// Open a channel, send keysend in both directions, then cooperatively close.
174194
pub(crate) async fn keysend_scenario<E: ElectrumApi>(
175195
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
@@ -188,8 +208,8 @@ pub(crate) async fn keysend_scenario<E: ElectrumApi>(
188208
channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
189209
}
190210

191-
/// 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>(
211+
/// Open a channel, send a BOLT11 payment, then force-close from the LDK side.
212+
pub(crate) async fn force_close_after_payment_bolt11_scenario<E: ElectrumApi>(
193213
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
194214
) {
195215
let (user_ch, ext_ch) = channel::open_channel_to_external(
@@ -201,7 +221,25 @@ pub(crate) async fn force_close_after_payment_scenario<E: ElectrumApi>(
201221
Some(500_000_000),
202222
)
203223
.await;
204-
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close").await;
224+
payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close-bolt11").await;
225+
wait_for_htlcs_settled(peer, &ext_ch).await;
226+
channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
227+
}
228+
229+
/// Open a channel, send a BOLT12 payment, then force-close from the LDK side.
230+
pub(crate) async fn force_close_after_payment_bolt12_scenario<E: ElectrumApi>(
231+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
232+
) {
233+
let (user_ch, ext_ch) = channel::open_channel_to_external(
234+
node,
235+
peer,
236+
bitcoind,
237+
electrs,
238+
1_000_000,
239+
Some(500_000_000),
240+
)
241+
.await;
242+
payment::send_bolt12_to_peer(node, peer, 5_000_000, "force-close-bolt12").await;
205243
wait_for_htlcs_settled(peer, &ext_ch).await;
206244
channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
207245
}
@@ -225,8 +263,33 @@ pub(crate) async fn disconnect_during_payment_scenario<E: ElectrumApi>(
225263
channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
226264
}
227265

228-
/// Open a channel, splice-in additional funds, send a post-splice payment, then close.
229-
pub(crate) async fn splice_in_scenario<E: ElectrumApi>(
266+
/// Open a channel, splice-in additional funds, send a post-splice BOLT11 payment, then close.
267+
pub(crate) async fn splice_in_bolt11_scenario<E: ElectrumApi>(
268+
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
269+
) {
270+
let (user_ch, ext_ch) = channel::open_channel_to_external(
271+
node,
272+
peer,
273+
bitcoind,
274+
electrs,
275+
1_000_000,
276+
Some(500_000_000),
277+
)
278+
.await;
279+
let ext_node_id = peer.get_node_id().await.unwrap();
280+
node.splice_in(&user_ch, ext_node_id, 500_000).unwrap();
281+
expect_splice_pending_event!(node, ext_node_id);
282+
generate_blocks_and_wait(bitcoind, electrs, 6).await;
283+
sync_wallets_with_retry(node).await;
284+
expect_channel_ready_event!(node, ext_node_id);
285+
286+
payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice-bolt11").await;
287+
288+
channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await;
289+
}
290+
291+
/// Open a channel, splice-in additional funds, send a post-splice BOLT12 payment, then close.
292+
pub(crate) async fn splice_in_bolt12_scenario<E: ElectrumApi>(
230293
node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E,
231294
) {
232295
let (user_ch, ext_ch) = channel::open_channel_to_external(
@@ -245,7 +308,7 @@ pub(crate) async fn splice_in_scenario<E: ElectrumApi>(
245308
sync_wallets_with_retry(node).await;
246309
expect_channel_ready_event!(node, ext_node_id);
247310

248-
payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice").await;
311+
payment::send_bolt12_to_peer(node, peer, 5_000_000, "post-splice-bolt12").await;
249312

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

tests/common/scenarios/payment.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77

88
use std::str::FromStr;
99

10-
use ldk_node::{Event, Node};
11-
use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description};
12-
1310
use super::super::external_node::ExternalNode;
1411
use super::retry_until_ok;
12+
use ldk_node::{Event, Node};
13+
use lightning::offers::offer::Offer;
14+
use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description};
1515

1616
/// LDK pays the peer via a fresh BOLT11 invoice; asserts `PaymentSuccessful`.
1717
pub(crate) async fn send_bolt11_to_peer(
@@ -23,6 +23,16 @@ pub(crate) async fn send_bolt11_to_peer(
2323
expect_event!(node, PaymentSuccessful);
2424
}
2525

26+
/// LDK pays the peer via a fresh BOLT12 offer; asserts `PaymentSuccessful`.
27+
pub(crate) async fn send_bolt12_to_peer(
28+
node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64, label: &str,
29+
) {
30+
let offer_str = peer.create_offer(amount_msat, label).await.unwrap();
31+
let parsed_offer = Offer::from_str(&offer_str).unwrap();
32+
node.bolt12_payment().send(&parsed_offer, None, None, None).unwrap();
33+
expect_event!(node, PaymentSuccessful);
34+
}
35+
2636
/// External node pays LDK via BOLT11 invoice. Retries to absorb gossip-propagation
2737
/// delay (peer may not yet know a route to LDK right after channel confirmation).
2838
pub(crate) async fn receive_bolt11_payment(
@@ -33,7 +43,7 @@ pub(crate) async fn receive_bolt11_payment(
3343
.receive(
3444
amount_msat,
3545
&Bolt11InvoiceDescription::Direct(
36-
Description::new("interop-receive-test".to_string()).unwrap(),
46+
Description::new("interop-receive-test-bolt11".to_string()).unwrap(),
3747
),
3848
3600,
3949
)
@@ -43,6 +53,21 @@ pub(crate) async fn receive_bolt11_payment(
4353
expect_payment_received_event!(node, amount_msat);
4454
}
4555

56+
/// External node pays LDK via BOLT12 offer. Retries to absorb gossip-propagation
57+
/// delay (peer may not yet know a route to LDK right after channel confirmation).
58+
pub(crate) async fn receive_bolt12_payment(
59+
node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64,
60+
) {
61+
let offer = node
62+
.bolt12_payment()
63+
.receive(amount_msat, "interop-receive-test-bolt12", Some(3600), Some(1))
64+
.unwrap();
65+
let offer_str = offer.to_string();
66+
retry_until_ok(10, "receive_bolt12_payment", || peer.pay_offer(&offer_str, Some(amount_msat)))
67+
.await;
68+
expect_payment_received_event!(node, amount_msat);
69+
}
70+
4671
/// LDK keysends to peer; asserts `PaymentSuccessful`.
4772
pub(crate) async fn send_keysend_to_peer(
4873
node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64,

0 commit comments

Comments
 (0)