Skip to content

Commit d3dcb00

Browse files
committed
Add hodl invoice support
Adds three new API endpoints for hodl invoices, which allow the receiver to inspect incoming payments before deciding whether to claim or reject them. Also add a `PaymentClaimable` event that is published when a hodl invoice payment arrives and is waiting to be manually claimed or failed. This is was needed when getting ldk-server to work with loop.
1 parent 3820ffd commit d3dcb00

File tree

15 files changed

+609
-7
lines changed

15 files changed

+609
-7
lines changed

e2e-tests/tests/e2e.rs

Lines changed: 144 additions & 0 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,
@@ -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, false), // preimage only
656+
([46u8; 32], None, true), // hash 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,

ldk-server-client/src/client.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
1212
use bitcoin_hashes::hmac::{Hmac, HmacEngine};
1313
use bitcoin_hashes::{sha256, Hash, HashEngine};
1414
use ldk_server_protos::api::{
15+
Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse, Bolt11FailForHashRequest,
16+
Bolt11FailForHashResponse, Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse,
1517
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse,
1618
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
1719
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
@@ -30,6 +32,7 @@ use ldk_server_protos::api::{
3032
VerifySignatureRequest, VerifySignatureResponse,
3133
};
3234
use ldk_server_protos::endpoints::{
35+
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
3336
BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
3437
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
3538
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
@@ -143,6 +146,34 @@ impl LdkServerClient {
143146
self.post_request(&request, &url).await
144147
}
145148

149+
/// Retrieve a new BOLT11 payable invoice for a given payment hash.
150+
/// The inbound payment will NOT be automatically claimed upon arrival.
151+
/// For API contract/usage, refer to docs for [`Bolt11ReceiveForHashRequest`] and [`Bolt11ReceiveForHashResponse`].
152+
pub async fn bolt11_receive_for_hash(
153+
&self, request: Bolt11ReceiveForHashRequest,
154+
) -> Result<Bolt11ReceiveForHashResponse, LdkServerError> {
155+
let url = format!("https://{}/{BOLT11_RECEIVE_FOR_HASH_PATH}", self.base_url);
156+
self.post_request(&request, &url).await
157+
}
158+
159+
/// Manually claim a payment for a given payment hash with the corresponding preimage.
160+
/// For API contract/usage, refer to docs for [`Bolt11ClaimForHashRequest`] and [`Bolt11ClaimForHashResponse`].
161+
pub async fn bolt11_claim_for_hash(
162+
&self, request: Bolt11ClaimForHashRequest,
163+
) -> Result<Bolt11ClaimForHashResponse, LdkServerError> {
164+
let url = format!("https://{}/{BOLT11_CLAIM_FOR_HASH_PATH}", self.base_url);
165+
self.post_request(&request, &url).await
166+
}
167+
168+
/// Manually fail a payment for a given payment hash.
169+
/// For API contract/usage, refer to docs for [`Bolt11FailForHashRequest`] and [`Bolt11FailForHashResponse`].
170+
pub async fn bolt11_fail_for_hash(
171+
&self, request: Bolt11FailForHashRequest,
172+
) -> Result<Bolt11FailForHashResponse, LdkServerError> {
173+
let url = format!("https://{}/{BOLT11_FAIL_FOR_HASH_PATH}", self.base_url);
174+
self.post_request(&request, &url).await
175+
}
176+
146177
/// Send a payment for a BOLT11 invoice.
147178
/// For API contract/usage, refer to docs for [`Bolt11SendRequest`] and [`Bolt11SendResponse`].
148179
pub async fn bolt11_send(

0 commit comments

Comments
 (0)