Skip to content

Commit 733ffeb

Browse files
authored
Merge pull request #150 from benthecarman/hodl-invoices
2 parents 0dc3a42 + e055d37 commit 733ffeb

16 files changed

Lines changed: 632 additions & 30 deletions

File tree

e2e-tests/src/lib.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,15 @@ pub async fn setup_funded_channel(
432432
pub struct RabbitMqEventConsumer {
433433
_connection: lapin::Connection,
434434
channel: lapin::Channel,
435-
queue_name: String,
435+
consumer: lapin::Consumer,
436436
}
437437

438438
impl RabbitMqEventConsumer {
439439
/// Connect to RabbitMQ and create an exclusive queue bound to the given exchange.
440440
pub async fn new(exchange_name: &str) -> Self {
441-
use lapin::options::{ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions};
441+
use lapin::options::{
442+
BasicConsumeOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions,
443+
};
442444
use lapin::types::FieldTable;
443445
use lapin::{ConnectionProperties, ExchangeKind};
444446

@@ -484,32 +486,30 @@ impl RabbitMqEventConsumer {
484486
.await
485487
.expect("Failed to bind queue");
486488

487-
Self { _connection: connection, channel, queue_name }
489+
let consumer = channel
490+
.basic_consume(
491+
&queue_name,
492+
&format!("consumer_{}", queue_name),
493+
BasicConsumeOptions::default(),
494+
FieldTable::default(),
495+
)
496+
.await
497+
.expect("Failed to start consumer");
498+
499+
Self { _connection: connection, channel, consumer }
488500
}
489501

490502
/// Consume up to `count` events, waiting up to `timeout` for each.
491503
pub async fn consume_events(
492-
&self, count: usize, timeout: Duration,
504+
&mut self, count: usize, timeout: Duration,
493505
) -> Vec<ldk_server_protos::events::EventEnvelope> {
494506
use futures_util::StreamExt;
495-
use lapin::options::{BasicAckOptions, BasicConsumeOptions};
496-
use lapin::types::FieldTable;
507+
use lapin::options::BasicAckOptions;
497508
use prost::Message;
498509

499-
let mut consumer = self
500-
.channel
501-
.basic_consume(
502-
&self.queue_name,
503-
&format!("consumer_{}", self.queue_name),
504-
BasicConsumeOptions::default(),
505-
FieldTable::default(),
506-
)
507-
.await
508-
.expect("Failed to start consumer");
509-
510510
let mut events = Vec::new();
511511
for _ in 0..count {
512-
match tokio::time::timeout(timeout, consumer.next()).await {
512+
match tokio::time::timeout(timeout, self.consumer.next()).await {
513513
Ok(Some(Ok(delivery))) => {
514514
let event = ldk_server_protos::events::EventEnvelope::decode(&*delivery.data)
515515
.expect("Failed to decode event");

e2e-tests/tests/e2e.rs

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use e2e_tests::{
1414
find_available_port, mine_and_sync, run_cli, run_cli_raw, setup_funded_channel,
1515
wait_for_onchain_balance, LdkServerHandle, RabbitMqEventConsumer, TestBitcoind,
1616
};
17+
use hex_conservative::DisplayHex;
18+
use ldk_node::bitcoin::hashes::{sha256, Hash};
1719
use ldk_node::lightning::ln::msgs::SocketAddress;
1820
use ldk_server_client::ldk_server_protos::api::{
1921
Bolt11ReceiveRequest, Bolt12ReceiveRequest, OnchainReceiveRequest,
@@ -271,8 +273,8 @@ async fn test_cli_bolt11_send() {
271273
let server_b = LdkServerHandle::start(&bitcoind).await;
272274

273275
// Set up event consumers before any payments
274-
let consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
275-
let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
276+
let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
277+
let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
276278

277279
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
278280

@@ -339,8 +341,8 @@ async fn test_cli_spontaneous_send() {
339341
let server_a = LdkServerHandle::start(&bitcoind).await;
340342
let server_b = LdkServerHandle::start(&bitcoind).await;
341343

342-
let consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
343-
let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
344+
let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
345+
let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
344346

345347
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
346348

@@ -564,7 +566,7 @@ async fn test_forwarded_payment_event() {
564566
let server_b = LdkServerHandle::start(&bitcoind).await;
565567

566568
// Set up RabbitMQ consumer on B before any payments
567-
let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
569+
let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
568570

569571
// Open channel A -> B (1M sats, larger for JIT forwarding)
570572
setup_funded_channel(&bitcoind, &server_a, &server_b, 1_000_000).await;
@@ -634,3 +636,145 @@ async fn test_forwarded_payment_event() {
634636

635637
node_c.stop().unwrap();
636638
}
639+
640+
#[tokio::test]
641+
async fn test_hodl_invoice_claim() {
642+
let bitcoind = TestBitcoind::new();
643+
let server_a = LdkServerHandle::start(&bitcoind).await;
644+
let server_b = LdkServerHandle::start(&bitcoind).await;
645+
646+
let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
647+
let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
648+
649+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
650+
651+
// Test three claim variants: (preimage, amount, hash)
652+
let test_cases: Vec<([u8; 32], Option<&str>, bool)> = vec![
653+
([42u8; 32], Some("10000000msat"), true), // all args
654+
([44u8; 32], Some("10000000msat"), false), // preimage + amount
655+
([45u8; 32], None, true), // preimage + hash
656+
([46u8; 32], None, false), // preimage only
657+
];
658+
659+
for (preimage_bytes, amount, include_hash) in &test_cases {
660+
let preimage_hex = preimage_bytes.to_lower_hex_string();
661+
let payment_hash_hex =
662+
sha256::Hash::hash(preimage_bytes).to_byte_array().to_lower_hex_string();
663+
664+
// Create hodl invoice on B
665+
let invoice_resp = run_cli(
666+
&server_b,
667+
&[
668+
"bolt11-receive-for-hash",
669+
&payment_hash_hex,
670+
"10000000msat",
671+
"-d",
672+
"hodl test",
673+
"-e",
674+
"3600",
675+
],
676+
);
677+
let invoice = invoice_resp["invoice"].as_str().unwrap();
678+
679+
// Pay the hodl invoice from A
680+
run_cli(&server_a, &["bolt11-send", invoice]);
681+
682+
// Verify PaymentClaimable event on B
683+
let events_b = consumer_b.consume_events(1, Duration::from_secs(10)).await;
684+
assert!(
685+
events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentClaimable(_)))),
686+
"Expected PaymentClaimable on receiver, got events: {:?}",
687+
events_b.iter().map(|e| &e.event).collect::<Vec<_>>()
688+
);
689+
690+
// Claim the payment on B
691+
let mut args: Vec<&str> = vec!["bolt11-claim-for-hash", &preimage_hex];
692+
if let Some(amt) = amount {
693+
args.extend(["-c", amt]);
694+
}
695+
if *include_hash {
696+
args.extend(["-p", &payment_hash_hex]);
697+
}
698+
run_cli(&server_b, &args);
699+
700+
// Verify PaymentReceived event on B
701+
let events_b = consumer_b.consume_events(1, Duration::from_secs(10)).await;
702+
assert!(
703+
events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentReceived(_)))),
704+
"Expected PaymentReceived on receiver after claim, got events: {:?}",
705+
events_b.iter().map(|e| &e.event).collect::<Vec<_>>()
706+
);
707+
708+
// Verify PaymentSuccessful on A
709+
let events_a = consumer_a.consume_events(1, Duration::from_secs(10)).await;
710+
assert!(
711+
events_a.iter().any(|e| matches!(&e.event, Some(Event::PaymentSuccessful(_)))),
712+
"Expected PaymentSuccessful on sender, got events: {:?}",
713+
events_a.iter().map(|e| &e.event).collect::<Vec<_>>()
714+
);
715+
}
716+
}
717+
718+
#[tokio::test]
719+
async fn test_hodl_invoice_fail() {
720+
use hex_conservative::DisplayHex;
721+
use ldk_node::bitcoin::hashes::{sha256, Hash};
722+
723+
let bitcoind = TestBitcoind::new();
724+
let server_a = LdkServerHandle::start(&bitcoind).await;
725+
let server_b = LdkServerHandle::start(&bitcoind).await;
726+
727+
// Set up event consumers before any payments
728+
let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await;
729+
let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await;
730+
731+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
732+
733+
// Generate a known preimage and compute its payment hash
734+
let preimage_bytes = [43u8; 32];
735+
let payment_hash = sha256::Hash::hash(&preimage_bytes);
736+
let payment_hash_hex = payment_hash.to_byte_array().to_lower_hex_string();
737+
738+
// Create hodl invoice on B
739+
let invoice_resp = run_cli(
740+
&server_b,
741+
&[
742+
"bolt11-receive-for-hash",
743+
&payment_hash_hex,
744+
"10000000msat",
745+
"-d",
746+
"hodl fail test",
747+
"-e",
748+
"3600",
749+
],
750+
);
751+
let invoice = invoice_resp["invoice"].as_str().unwrap();
752+
753+
// Pay the hodl invoice from A
754+
run_cli(&server_a, &["bolt11-send", invoice]);
755+
756+
// Wait for payment to arrive at B
757+
tokio::time::sleep(Duration::from_secs(5)).await;
758+
759+
// Verify PaymentClaimable event on B
760+
let events_b = consumer_b.consume_events(5, Duration::from_secs(10)).await;
761+
assert!(
762+
events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentClaimable(_)))),
763+
"Expected PaymentClaimable on receiver, got events: {:?}",
764+
events_b.iter().map(|e| &e.event).collect::<Vec<_>>()
765+
);
766+
767+
// Fail the payment on B using CLI
768+
run_cli(&server_b, &["bolt11-fail-for-hash", &payment_hash_hex]);
769+
770+
// Wait for failure to propagate
771+
tokio::time::sleep(Duration::from_secs(5)).await;
772+
773+
// Verify PaymentFailed on A
774+
let events_a = consumer_a.consume_events(10, Duration::from_secs(10)).await;
775+
assert!(
776+
events_a.iter().any(|e| matches!(&e.event, Some(Event::PaymentFailed(_)))),
777+
"Expected PaymentFailed on sender after hodl rejection, got events: {:?}",
778+
events_a.iter().map(|e| &e.event).collect::<Vec<_>>()
779+
);
780+
}

ldk-server-cli/src/main.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ use ldk_server_client::error::LdkServerErrorCode::{
2222
AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError,
2323
};
2424
use ldk_server_client::ldk_server_protos::api::{
25+
Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse, Bolt11FailForHashRequest,
26+
Bolt11FailForHashResponse, Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse,
2527
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse,
2628
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
2729
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
@@ -134,6 +136,48 @@ enum Commands {
134136
#[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")]
135137
expiry_secs: Option<u32>,
136138
},
139+
#[command(
140+
about = "Create a BOLT11 hodl invoice for a given payment hash (manual claim required)"
141+
)]
142+
Bolt11ReceiveForHash {
143+
#[arg(help = "The hex-encoded 32-byte payment hash")]
144+
payment_hash: String,
145+
#[arg(
146+
help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount invoice is returned"
147+
)]
148+
amount: Option<Amount>,
149+
#[arg(short, long, help = "Description to attach along with the invoice")]
150+
description: Option<String>,
151+
#[arg(
152+
long,
153+
help = "SHA-256 hash of the description (hex). Use instead of description for longer text"
154+
)]
155+
description_hash: Option<String>,
156+
#[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")]
157+
expiry_secs: Option<u32>,
158+
},
159+
#[command(about = "Claim a held payment by providing the preimage")]
160+
Bolt11ClaimForHash {
161+
#[arg(help = "The hex-encoded 32-byte payment preimage")]
162+
preimage: String,
163+
#[arg(
164+
short,
165+
long,
166+
help = "The claimable amount, e.g. 50sat or 50000msat, only used for verifying we are claiming the expected amount"
167+
)]
168+
claimable_amount: Option<Amount>,
169+
#[arg(
170+
short,
171+
long,
172+
help = "The hex-encoded 32-byte payment hash, used to verify the preimage matches"
173+
)]
174+
payment_hash: Option<String>,
175+
},
176+
#[command(about = "Fail/reject a held payment")]
177+
Bolt11FailForHash {
178+
#[arg(help = "The hex-encoded 32-byte payment hash")]
179+
payment_hash: String,
180+
},
137181
#[command(about = "Pay a BOLT11 invoice")]
138182
Bolt11Send {
139183
#[arg(help = "A BOLT11 invoice for a payment within the Lightning Network")]
@@ -551,6 +595,58 @@ async fn main() {
551595
client.bolt11_receive(request).await,
552596
);
553597
},
598+
Commands::Bolt11ReceiveForHash {
599+
payment_hash,
600+
amount,
601+
description,
602+
description_hash,
603+
expiry_secs,
604+
} => {
605+
let amount_msat = amount.map(|a| a.to_msat());
606+
let invoice_description = match (description, description_hash) {
607+
(Some(desc), None) => Some(Bolt11InvoiceDescription {
608+
kind: Some(bolt11_invoice_description::Kind::Direct(desc)),
609+
}),
610+
(None, Some(hash)) => Some(Bolt11InvoiceDescription {
611+
kind: Some(bolt11_invoice_description::Kind::Hash(hash)),
612+
}),
613+
(Some(_), Some(_)) => {
614+
handle_error(LdkServerError::new(
615+
InternalError,
616+
"Only one of description or description_hash can be set.".to_string(),
617+
));
618+
},
619+
(None, None) => None,
620+
};
621+
622+
let expiry_secs = expiry_secs.unwrap_or(DEFAULT_EXPIRY_SECS);
623+
let request = Bolt11ReceiveForHashRequest {
624+
description: invoice_description,
625+
expiry_secs,
626+
amount_msat,
627+
payment_hash,
628+
};
629+
630+
handle_response_result::<_, Bolt11ReceiveForHashResponse>(
631+
client.bolt11_receive_for_hash(request).await,
632+
);
633+
},
634+
Commands::Bolt11ClaimForHash { preimage, claimable_amount, payment_hash } => {
635+
handle_response_result::<_, Bolt11ClaimForHashResponse>(
636+
client
637+
.bolt11_claim_for_hash(Bolt11ClaimForHashRequest {
638+
payment_hash,
639+
claimable_amount_msat: claimable_amount.map(|a| a.to_msat()),
640+
preimage,
641+
})
642+
.await,
643+
);
644+
},
645+
Commands::Bolt11FailForHash { payment_hash } => {
646+
handle_response_result::<_, Bolt11FailForHashResponse>(
647+
client.bolt11_fail_for_hash(Bolt11FailForHashRequest { payment_hash }).await,
648+
);
649+
},
554650
Commands::Bolt11Send {
555651
invoice,
556652
amount,

0 commit comments

Comments
 (0)