Skip to content

Commit 4412a7c

Browse files
Add create_contact_offer for compact BLIP-42 offers
Adds `create_contact_offer` method to `Bolt12Payment` that creates compact offers suitable for BLIP-42's `payer_offer` field. These offers have either no blinded paths or single-hop paths, making them small enough to embed in invoice requests. Also adds integration test for the complete BLIP-42 flow: - Creating compact contact offers - Sending payments with contact info - Receiving payments with BLIP-42 fields Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
1 parent 2a8cdde commit 4412a7c

5 files changed

Lines changed: 222 additions & 8 deletions

File tree

Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ name = "payments"
129129
harness = false
130130

131131
#[patch.crates-io]
132+
#lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
133+
#lightning-types = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
134+
#lightning-invoice = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
135+
#lightning-net-tokio = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
136+
#lightning-persister = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
137+
#lightning-background-processor = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
138+
#lightning-rapid-gossip-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
139+
#lightning-block-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
140+
#lightning-transaction-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
141+
#lightning-liquidity = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
142+
#lightning-macros = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" }
143+
132144
#lightning = { path = "../rust-lightning/lightning" }
133145
#lightning-types = { path = "../rust-lightning/lightning-types" }
134146
#lightning-invoice = { path = "../rust-lightning/lightning-invoice" }

bindings/ldk_node.udl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ interface Bolt12Payment {
237237
Offer receive(u64 amount_msat, [ByRef]string description, u32? expiry_secs, u64? quantity);
238238
[Throws=NodeError]
239239
Offer receive_variable_amount([ByRef]string description, u32? expiry_secs);
240+
/// Creates a compact contact offer for BLIP-42's payer_offer field.
241+
[Throws=NodeError]
242+
Offer create_contact_offer(PublicKey? intro_node);
240243
[Throws=NodeError]
241244
Bolt12Invoice request_refund_payment([ByRef]Refund refund);
242245
[Throws=NodeError]

src/io/test_utils.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ use std::sync::Mutex;
1313

1414
use lightning::events::ClosureReason;
1515
use lightning::ln::functional_test_utils::{
16-
check_closed_event, connect_block, create_announced_chan_between_nodes, create_chanmon_cfgs,
17-
create_dummy_block, create_network, create_node_cfgs, create_node_chanmgrs, send_payment,
18-
TestChanMonCfg,
16+
check_added_monitors, check_closed_broadcast, check_closed_event, connect_block,
17+
create_announced_chan_between_nodes, create_chanmon_cfgs, create_dummy_block, create_network,
18+
create_node_cfgs, create_node_chanmgrs, send_payment, TestChanMonCfg,
1919
};
2020
use lightning::util::persist::{
2121
KVStore, KVStoreSync, MonitorUpdatingPersister, KVSTORE_NAMESPACE_KEY_MAX_LEN,
2222
};
2323
use lightning::util::test_utils;
24-
use lightning::{check_added_monitors, check_closed_broadcast, io};
24+
use lightning::io;
2525
use rand::distr::Alphanumeric;
2626
use rand::{rng, Rng};
2727

@@ -332,20 +332,20 @@ pub(crate) fn do_test_store<K: KVStoreSync + Sync>(store_0: &K, store_1: &K) {
332332
&[nodes[1].node.get_our_node_id()],
333333
100000,
334334
);
335-
check_closed_broadcast!(nodes[0], true);
336-
check_added_monitors!(nodes[0], 1);
335+
check_closed_broadcast(&nodes[0], 1, true);
336+
check_added_monitors(&nodes[0], 1);
337337

338338
let node_txn = nodes[0].tx_broadcaster.txn_broadcast();
339339
assert_eq!(node_txn.len(), 1);
340340
let txn = vec![node_txn[0].clone(), node_txn[0].clone()];
341341
let dummy_block = create_dummy_block(nodes[0].best_block_hash(), 42, txn);
342342
connect_block(&nodes[1], &dummy_block);
343343

344-
check_closed_broadcast!(nodes[1], true);
344+
check_closed_broadcast(&nodes[1], 1, true);
345345
let reason = ClosureReason::CommitmentTxConfirmed;
346346
let node_id_0 = nodes[0].node.get_our_node_id();
347347
check_closed_event(&nodes[1], 1, reason, &[node_id_0], 100000);
348-
check_added_monitors!(nodes[1], 1);
348+
check_added_monitors(&nodes[1], 1);
349349

350350
// Make sure everything is persisted as expected after close.
351351
check_persisted_data!(persister_0_max_pending_updates * 2 * EXPECTED_UPDATES_PER_PAYMENT + 1);

src/payment/bolt12.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::num::NonZeroU64;
1313
use std::sync::{Arc, RwLock};
1414
use std::time::{Duration, SystemTime, UNIX_EPOCH};
1515

16+
use bitcoin::secp256k1::PublicKey;
1617
use lightning::blinded_path::message::BlindedMessagePath;
1718
use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId, Retry};
1819
use lightning::offers::contacts::ContactSecrets as LdkContactSecrets;
@@ -658,6 +659,74 @@ impl Bolt12Payment {
658659
Ok(maybe_wrap(offer))
659660
}
660661

662+
/// Creates a compact contact offer suitable for BLIP-42's `payer_offer` field.
663+
///
664+
/// Contact offers are designed to be embedded in invoice requests and should be
665+
/// as compact as possible while still being payable. Unlike regular offers created
666+
/// by [`receive`] or [`receive_variable_amount`], contact offers have minimal or
667+
/// no blinded paths.
668+
///
669+
/// # Privacy Modes
670+
///
671+
/// - `intro_node: None` - Creates an offer with no blinded paths. The offer exposes
672+
/// the node's derived signing pubkey directly. This is the most compact form but
673+
/// provides no path privacy. Suitable when privacy is not a concern or when the
674+
/// offer will only be shared with trusted contacts.
675+
///
676+
/// - `intro_node: Some(node_id)` - Creates an offer with a single blinded path through
677+
/// the specified introduction node. The intro node should be a well-connected,
678+
/// trusted peer that can route onion messages to this node.
679+
///
680+
/// # Example
681+
///
682+
/// ```ignore
683+
/// // Create a compact contact offer (no privacy)
684+
/// let contact_offer = node.bolt12_payment()
685+
/// .create_contact_offer(None)
686+
/// .unwrap();
687+
///
688+
/// // Create a contact offer with privacy through a trusted peer
689+
/// let contact_offer = node.bolt12_payment()
690+
/// .create_contact_offer(Some(trusted_peer_id))
691+
/// .unwrap();
692+
///
693+
/// // Use it when paying someone with BLIP-42 contact info
694+
/// let payment_id = node.bolt12_payment()
695+
/// .send_with_contact(
696+
/// &their_offer, None, None, None,
697+
/// Some(contact_secrets),
698+
/// Some(contact_offer),
699+
/// )
700+
/// .unwrap();
701+
/// ```
702+
///
703+
/// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
704+
///
705+
/// [`receive`]: Self::receive
706+
/// [`receive_variable_amount`]: Self::receive_variable_amount
707+
pub fn create_contact_offer(&self, intro_node: Option<PublicKey>) -> Result<Offer, Error> {
708+
let offer = self
709+
.channel_manager
710+
.create_compact_offer_builder(intro_node)
711+
.map_err(|e| {
712+
log_error!(self.logger, "Failed to create compact offer builder: {:?}", e);
713+
Error::OfferCreationFailed
714+
})?
715+
.build()
716+
.map_err(|e| {
717+
log_error!(self.logger, "Failed to build contact offer: {:?}", e);
718+
Error::OfferCreationFailed
719+
})?;
720+
721+
log_info!(
722+
self.logger,
723+
"Created contact offer with intro_node: {:?}",
724+
intro_node.map(|n| n.to_string())
725+
);
726+
727+
Ok(maybe_wrap(offer))
728+
}
729+
661730
/// Requests a refund payment for the given [`Refund`].
662731
///
663732
/// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to

tests/integration_tests_rust.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,136 @@ async fn simple_bolt12_send_receive() {
13031303
assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount));
13041304
}
13051305

1306+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1307+
async fn bolt12_with_blip42_contact() {
1308+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
1309+
let chain_source = TestChainSource::Esplora(&electrsd);
1310+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
1311+
1312+
let address_a = node_a.onchain_payment().new_address().unwrap();
1313+
let premine_amount_sat = 5_000_000;
1314+
premine_and_distribute_funds(
1315+
&bitcoind.client,
1316+
&electrsd.client,
1317+
vec![address_a],
1318+
Amount::from_sat(premine_amount_sat),
1319+
)
1320+
.await;
1321+
1322+
node_a.sync_wallets().unwrap();
1323+
open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await;
1324+
1325+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
1326+
1327+
node_a.sync_wallets().unwrap();
1328+
node_b.sync_wallets().unwrap();
1329+
1330+
expect_channel_ready_event!(node_a, node_b.node_id());
1331+
expect_channel_ready_event!(node_b, node_a.node_id());
1332+
1333+
// Sleep until we broadcasted a node announcement.
1334+
while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() {
1335+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1336+
}
1337+
while node_a.status().latest_node_announcement_broadcast_timestamp.is_none() {
1338+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1339+
}
1340+
1341+
// Sleep one more sec to make sure the node announcements propagate.
1342+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1343+
1344+
// Node B creates an offer to receive payment
1345+
let expected_amount_msat = 100_000_000;
1346+
let node_b_offer =
1347+
node_b.bolt12_payment().receive(expected_amount_msat, "test payment", None, Some(1)).unwrap();
1348+
1349+
// Node A creates a COMPACT contact offer for BLIP-42's payer_offer field.
1350+
// Using None for intro_node creates an offer with no blinded paths (maximum compactness).
1351+
// This is suitable for embedding in invoice requests per BLIP-42 specification.
1352+
let node_a_offer = node_a.bolt12_payment().create_contact_offer(None).unwrap();
1353+
1354+
// Verify the contact offer is compact (either no paths or single-hop paths)
1355+
assert!(
1356+
node_a_offer.paths().is_empty()
1357+
|| node_a_offer.paths().iter().all(|p| p.blinded_hops().len() <= 1),
1358+
"Contact offer should be compact with no paths or single-hop paths"
1359+
);
1360+
1361+
// Create a contact secret (32 random bytes)
1362+
use ldk_node::payment::{ContactSecret, ContactSecrets};
1363+
let contact_secret_bytes: [u8; 32] = std::array::from_fn(|i| i as u8);
1364+
let contact_secret = ContactSecret::new(contact_secret_bytes);
1365+
let contact_secrets = ContactSecrets::new(contact_secret);
1366+
1367+
// Node A sends payment to Node B with BLIP-42 contact info
1368+
let payment_id = node_a
1369+
.bolt12_payment()
1370+
.send_with_contact(
1371+
&node_b_offer,
1372+
Some(1),
1373+
Some("BLIP-42 test".to_string()),
1374+
None,
1375+
Some(contact_secrets),
1376+
Some(node_a_offer.clone()),
1377+
)
1378+
.unwrap();
1379+
1380+
expect_payment_successful_event!(node_a, Some(payment_id), None);
1381+
1382+
// Node B should receive the payment with BLIP-42 contact information
1383+
let event = node_b.next_event_async().await;
1384+
match event {
1385+
Event::PaymentReceived {
1386+
amount_msat,
1387+
contact_secret: received_contact_secret,
1388+
payer_offer: received_payer_offer,
1389+
..
1390+
} => {
1391+
println!("Node B received payment with BLIP-42 contact info");
1392+
assert_eq!(amount_msat, expected_amount_msat);
1393+
1394+
// Verify contact_secret is present and matches
1395+
assert!(received_contact_secret.is_some(), "Expected contact_secret to be Some");
1396+
assert_eq!(
1397+
received_contact_secret.unwrap(),
1398+
contact_secret_bytes.to_vec(),
1399+
"Contact secret mismatch"
1400+
);
1401+
1402+
// Verify payer_offer is present
1403+
assert!(received_payer_offer.is_some(), "Expected payer_offer to be Some");
1404+
let payer_offer_str = received_payer_offer.unwrap();
1405+
1406+
// Parse the payer_offer and verify it matches node_a's offer
1407+
let parsed_offer: lightning::offers::offer::Offer =
1408+
payer_offer_str.parse().expect("Failed to parse payer_offer");
1409+
assert_eq!(
1410+
parsed_offer.id(),
1411+
node_a_offer.id(),
1412+
"Parsed offer ID should match node A's offer"
1413+
);
1414+
1415+
node_b.event_handled().unwrap();
1416+
},
1417+
ref e => {
1418+
panic!("Expected PaymentReceived event, got: {:?}", e);
1419+
},
1420+
}
1421+
1422+
// Verify payment records
1423+
let node_a_payments =
1424+
node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. }));
1425+
assert_eq!(node_a_payments.len(), 1);
1426+
assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(expected_amount_msat));
1427+
assert_eq!(node_a_payments.first().unwrap().status, PaymentStatus::Succeeded);
1428+
1429+
let node_b_payments =
1430+
node_b.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. }));
1431+
assert_eq!(node_b_payments.len(), 1);
1432+
assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(expected_amount_msat));
1433+
assert_eq!(node_b_payments.first().unwrap().status, PaymentStatus::Succeeded);
1434+
}
1435+
13061436
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
13071437
async fn async_payment() {
13081438
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)