Skip to content

Commit db32dda

Browse files
authored
Merge pull request #262 from benthecarman/2026-08-pruned-bitcoind-docs
Expose BOLT 12 refunds over gRPC
2 parents 7f3276a + b661d5f commit db32dda

16 files changed

Lines changed: 567 additions & 83 deletions

File tree

docs/api-guide.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ when the invoice is paid.
126126
| `Bolt11ReceiveViaJitChannel` | Create a fixed-amount invoice with JIT channel opening |
127127
| `Bolt11ReceiveVariableAmountViaJitChannel` | Create a variable-amount invoice with JIT channel opening |
128128

129-
### BOLT12 Offers
130-
131-
| RPC | Description |
132-
|-----------------|-------------------------------------------------------------------------|
133-
| `Bolt12Receive` | Create a BOLT12 offer (fixed or variable amount) |
134-
| `Bolt12Send` | Pay a BOLT12 offer (with optional quantity, payer note, routing config) |
129+
### BOLT12 Offers and Refunds
130+
131+
| RPC | Description |
132+
|-----------------------|-------------------------------------------------------------------------|
133+
| `Bolt12Receive` | Create a BOLT12 offer (fixed or variable amount) |
134+
| `Bolt12Send` | Pay a BOLT12 offer (with optional quantity, payer note, routing config) |
135+
| `Bolt12SendRefund` | Create a BOLT12 refund that this node will pay |
136+
| `Bolt12ReceiveRefund` | Request an incoming payment for a BOLT12 refund |
135137

136138
### Spontaneous and Unified Send
137139

e2e-tests/src/lib.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ use std::time::Duration;
1515

1616
use corepc_node::Node;
1717
use hex_conservative::DisplayHex;
18-
use ldk_server_client::client::LdkServerClient;
18+
use ldk_server_client::client::{EventStream, LdkServerClient};
1919
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
20+
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
21+
use ldk_server_client::ldk_server_grpc::events::EventEnvelope;
2022
use ldk_server_grpc::api::{
2123
open_channel_request, GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest,
2224
OpenChannelRequest,
2325
};
2426
use serde_json::Value;
2527

28+
const EVENT_TIMEOUT: Duration = Duration::from_secs(15);
29+
2630
/// Wrapper around a managed bitcoind process for regtest.
2731
pub struct TestBitcoind {
2832
pub bitcoind: Node,
@@ -487,6 +491,22 @@ pub async fn wait_for_file(path: &Path, timeout: Duration) {
487491
}
488492
}
489493

494+
/// Wait for the next event that matches the predicate.
495+
pub async fn wait_for_event(
496+
events: &mut EventStream, pred: impl Fn(&Event) -> bool,
497+
) -> EventEnvelope {
498+
tokio::time::timeout(EVENT_TIMEOUT, async {
499+
while let Some(Ok(event)) = events.next_message().await {
500+
if event.event.as_ref().is_some_and(&pred) {
501+
return event;
502+
}
503+
}
504+
panic!("Event stream ended without matching event");
505+
})
506+
.await
507+
.expect("Timed out waiting for event")
508+
}
509+
490510
/// Poll get_node_info until the server responds successfully.
491511
async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse {
492512
let start = std::time::Instant::now();

e2e-tests/tests/e2e.rs

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,43 +13,28 @@ use std::time::Duration;
1313

1414
use e2e_tests::{
1515
find_available_port, mine_and_sync, run_cli, run_cli_raw, run_cli_with_config,
16-
setup_funded_channel, wait_for_onchain_balance, wait_for_usable_channel, LdkServerConfig,
17-
LdkServerHandle, TestBitcoind,
16+
setup_funded_channel, wait_for_event, wait_for_onchain_balance, wait_for_usable_channel,
17+
LdkServerConfig, LdkServerHandle, TestBitcoind,
1818
};
1919
use hex_conservative::{DisplayHex, FromHex};
2020
use ldk_node::bitcoin::hashes::{sha256, Hash};
2121
use ldk_node::lightning::ln::msgs::SocketAddress;
2222
use ldk_node::lightning::offers::offer::Offer;
23+
use ldk_node::lightning::offers::refund::Refund;
2324
use ldk_node::lightning_invoice::Bolt11Invoice;
24-
use ldk_server_client::client::EventStream;
2525
use ldk_server_client::ldk_server_grpc::api::{
2626
open_channel_request, Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetBalancesRequest,
2727
OnchainReceiveRequest, OpenChannelRequest,
2828
};
2929
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
3030
use ldk_server_client::ldk_server_grpc::events::{
31-
ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, EventEnvelope,
31+
ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind,
3232
};
3333
use ldk_server_client::ldk_server_grpc::types::{
3434
bolt11_invoice_description, Bolt11InvoiceDescription,
3535
};
3636
use ldk_server_grpc::types::payment_kind;
3737

38-
const EVENT_TIMEOUT: Duration = Duration::from_secs(15);
39-
40-
async fn wait_for_event(events: &mut EventStream, pred: impl Fn(&Event) -> bool) -> EventEnvelope {
41-
tokio::time::timeout(EVENT_TIMEOUT, async {
42-
while let Some(Ok(ev)) = events.next_message().await {
43-
if ev.event.as_ref().is_some_and(&pred) {
44-
return ev;
45-
}
46-
}
47-
panic!("Event stream ended without matching event");
48-
})
49-
.await
50-
.expect("Timed out waiting for event")
51-
}
52-
5338
#[tokio::test]
5439
async fn test_cli_get_node_info() {
5540
let bitcoind = TestBitcoind::new();
@@ -987,6 +972,55 @@ async fn test_cli_bolt12_send() {
987972
assert!(!output["payment_id"].as_str().unwrap().is_empty());
988973
}
989974

975+
#[tokio::test]
976+
async fn test_cli_bolt12_refund() {
977+
let bitcoind = TestBitcoind::new();
978+
let server_a = LdkServerHandle::start(&bitcoind).await;
979+
let server_b = LdkServerHandle::start(&bitcoind).await;
980+
let mut events_a = server_a.client().subscribe_events().await.unwrap();
981+
let mut events_b = server_b.client().subscribe_events().await.unwrap();
982+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
983+
984+
// Give B outbound liquidity for the refund payment.
985+
let offer = server_b
986+
.client()
987+
.bolt12_receive(Bolt12ReceiveRequest {
988+
description: "refund funding payment".to_string(),
989+
amount_msat: Some(10_000_000),
990+
expiry_secs: None,
991+
quantity: None,
992+
})
993+
.await
994+
.unwrap();
995+
run_cli(&server_a, &["bolt12-send", &offer.offer]);
996+
wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentSuccessful(_))).await;
997+
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await;
998+
999+
let output = run_cli(
1000+
&server_b,
1001+
&["bolt12-send-refund", "5000sat", "--quantity", "1", "--payer-note", "test refund"],
1002+
);
1003+
let refund_str = output["refund"].as_str().unwrap();
1004+
assert!(refund_str.starts_with("lnr"), "Expected lnr prefix, got: {refund_str}");
1005+
let refund = Refund::from_str(refund_str).unwrap();
1006+
assert_eq!(refund.amount_msats(), 5_000_000);
1007+
assert_eq!(refund.quantity(), Some(1));
1008+
assert_eq!(refund.payer_note().unwrap().to_string(), "test refund");
1009+
1010+
let output = run_cli(&server_a, &["bolt12-receive-refund", refund_str]);
1011+
let payment_hash = output["payment_hash"].as_str().unwrap();
1012+
let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentReceived(_))).await;
1013+
let Some(Event::PaymentReceived(payment_received)) = event_a.event else {
1014+
panic!("expected PaymentReceived");
1015+
};
1016+
let payment = payment_received.payment.unwrap();
1017+
let Some(payment_kind::Kind::Bolt12Refund(refund)) = payment.kind.unwrap().kind else {
1018+
panic!("expected BOLT12 refund kind");
1019+
};
1020+
assert_eq!(refund.hash.as_deref(), Some(payment_hash));
1021+
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentSuccessful(_))).await;
1022+
}
1023+
9901024
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
9911025
async fn test_cli_spontaneous_send() {
9921026
let bitcoind = TestBitcoind::new();

e2e-tests/tests/mcp.rs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10-
use e2e_tests::{LdkServerHandle, McpHandle, TestBitcoind};
10+
use std::str::FromStr;
11+
12+
use e2e_tests::{setup_funded_channel, wait_for_event, LdkServerHandle, McpHandle, TestBitcoind};
13+
use ldk_node::lightning::offers::refund::Refund;
1114
use ldk_server_client::ldk_server_grpc::api::Bolt11ReceiveRequest;
15+
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
1216
use ldk_server_client::ldk_server_grpc::types::{
13-
bolt11_invoice_description, Bolt11InvoiceDescription,
17+
bolt11_invoice_description, payment_kind, Bolt11InvoiceDescription,
1418
};
15-
use serde_json::json;
19+
use serde_json::{json, Value};
20+
21+
fn tool_result_json(response: &Value) -> Value {
22+
let text = response["result"]["content"][0]["text"].as_str().unwrap();
23+
serde_json::from_str(text).unwrap()
24+
}
1625

1726
#[tokio::test]
1827
async fn test_mcp_initialize_and_list_tools() {
@@ -49,17 +58,14 @@ async fn test_mcp_live_tool_calls() {
4958
"name": "get_node_info",
5059
"arguments": {}
5160
}));
52-
let node_info_text = node_info["result"]["content"][0]["text"].as_str().unwrap();
53-
let node_info_json: serde_json::Value = serde_json::from_str(node_info_text).unwrap();
61+
let node_info_json = tool_result_json(&node_info);
5462
assert_eq!(node_info_json["node_id"], server.node_id());
5563

5664
let onchain_receive = mcp.call(2, "tools/call", json!({
5765
"name": "onchain_receive",
5866
"arguments": {}
5967
}));
60-
let onchain_receive_text = onchain_receive["result"]["content"][0]["text"].as_str().unwrap();
61-
let onchain_receive_json: serde_json::Value =
62-
serde_json::from_str(onchain_receive_text).unwrap();
68+
let onchain_receive_json = tool_result_json(&onchain_receive);
6369
assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1"));
6470

6571
let invoice = server
@@ -78,10 +84,63 @@ async fn test_mcp_live_tool_calls() {
7884
"name": "decode_invoice",
7985
"arguments": { "invoice": invoice.invoice }
8086
}));
81-
let decode_invoice_text = decode_invoice["result"]["content"][0]["text"].as_str().unwrap();
82-
let decode_invoice_json: serde_json::Value =
83-
serde_json::from_str(decode_invoice_text).unwrap();
87+
let decode_invoice_json = tool_result_json(&decode_invoice);
8488
assert_eq!(decode_invoice_json["destination"], server.node_id());
8589
assert_eq!(decode_invoice_json["description"], "mcp decode");
8690
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
8791
}
92+
93+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
94+
async fn test_mcp_bolt12_refund() {
95+
let bitcoind = TestBitcoind::new();
96+
let server_a = LdkServerHandle::start(&bitcoind).await;
97+
let server_b = LdkServerHandle::start(&bitcoind).await;
98+
let mut events_a = server_a.client().subscribe_events().await.unwrap();
99+
let mut events_b = server_b.client().subscribe_events().await.unwrap();
100+
101+
setup_funded_channel(&bitcoind, &server_b, &server_a, 100_000).await;
102+
103+
let mut mcp_a = McpHandle::start(&server_a);
104+
let mut mcp_b = McpHandle::start(&server_b);
105+
let send_refund = mcp_b.call(
106+
1,
107+
"tools/call",
108+
json!({
109+
"name": "bolt12_send_refund",
110+
"arguments": {
111+
"amount_msat": 5_000_000,
112+
"quantity": 1,
113+
"payer_note": "mcp refund"
114+
}
115+
}),
116+
);
117+
let send_refund = tool_result_json(&send_refund);
118+
let refund_str = send_refund["refund"].as_str().unwrap();
119+
let refund = Refund::from_str(refund_str).unwrap();
120+
assert_eq!(refund.amount_msats(), 5_000_000);
121+
assert_eq!(refund.quantity(), Some(1));
122+
assert_eq!(refund.payer_note().unwrap().to_string(), "mcp refund");
123+
124+
let receive_refund = mcp_a.call(
125+
1,
126+
"tools/call",
127+
json!({
128+
"name": "bolt12_receive_refund",
129+
"arguments": { "refund": refund_str }
130+
}),
131+
);
132+
let receive_refund = tool_result_json(&receive_refund);
133+
let payment_hash = receive_refund["payment_hash"].as_str().unwrap();
134+
135+
let event_a =
136+
wait_for_event(&mut events_a, |event| matches!(event, Event::PaymentReceived(_))).await;
137+
let Some(Event::PaymentReceived(payment_received)) = event_a.event else {
138+
panic!("expected PaymentReceived");
139+
};
140+
let payment = payment_received.payment.unwrap();
141+
let Some(payment_kind::Kind::Bolt12Refund(refund)) = payment.kind.unwrap().kind else {
142+
panic!("expected BOLT12 refund kind");
143+
};
144+
assert_eq!(refund.hash.as_deref(), Some(payment_hash));
145+
wait_for_event(&mut events_b, |event| matches!(event, Event::PaymentSuccessful(_))).await;
146+
}

ldk-server-cli/src/main.rs

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ use ldk_server_client::ldk_server_grpc::api::{
2929
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11ReceiveVariableAmountViaJitChannelRequest,
3030
Bolt11ReceiveVariableAmountViaJitChannelResponse, Bolt11ReceiveViaJitChannelRequest,
3131
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
32-
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12ReceiveRequest,
33-
Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest,
34-
CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, DecodeInvoiceRequest,
35-
DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest,
36-
DisconnectPeerResponse, ExportPathfindingScoresRequest, ForceCloseChannelRequest,
37-
ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest,
38-
GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
32+
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12ReceiveRefundRequest,
33+
Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse,
34+
Bolt12SendRefundRequest, Bolt12SendRefundResponse, Bolt12SendRequest, Bolt12SendResponse,
35+
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
36+
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
37+
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
38+
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
39+
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
3940
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
4041
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
4142
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
@@ -319,6 +320,43 @@ enum Commands {
319320
)]
320321
max_channel_saturation_power_of_half: Option<u32>,
321322
},
323+
#[command(about = "Create a BOLT12 refund")]
324+
Bolt12SendRefund {
325+
#[arg(help = "Amount to refund, e.g. 50sat or 50000msat")]
326+
amount: Amount,
327+
#[arg(long, default_value_t = DEFAULT_EXPIRY_SECS, help = "Refund expiry time in seconds")]
328+
expiry_secs: u32,
329+
#[arg(short, long, help = "Number of items being refunded")]
330+
quantity: Option<u64>,
331+
#[arg(
332+
short,
333+
long,
334+
help = "Note to include for the recipient. Will be reflected back in the invoice"
335+
)]
336+
payer_note: Option<String>,
337+
#[arg(
338+
long,
339+
help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of the payment amount + 50 sats"
340+
)]
341+
max_total_routing_fee: Option<Amount>,
342+
#[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")]
343+
max_total_cltv_expiry_delta: Option<u32>,
344+
#[arg(
345+
long,
346+
help = "Maximum number of paths that may be used by MPP payments (default: 10)"
347+
)]
348+
max_path_count: Option<u32>,
349+
#[arg(
350+
long,
351+
help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)"
352+
)]
353+
max_channel_saturation_power_of_half: Option<u32>,
354+
},
355+
#[command(about = "Request payment for a BOLT12 refund")]
356+
Bolt12ReceiveRefund {
357+
#[arg(help = "A BOLT12 refund from the node that will send the payment")]
358+
refund: String,
359+
},
322360
#[command(about = "Send a spontaneous payment (keysend) to a node")]
323361
SpontaneousSend {
324362
#[arg(help = "The hex-encoded public key of the node to send the payment to")]
@@ -872,6 +910,43 @@ async fn main() {
872910
.await,
873911
);
874912
},
913+
Commands::Bolt12SendRefund {
914+
amount,
915+
expiry_secs,
916+
quantity,
917+
payer_note,
918+
max_total_routing_fee,
919+
max_total_cltv_expiry_delta,
920+
max_path_count,
921+
max_channel_saturation_power_of_half,
922+
} => {
923+
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
924+
let route_parameters = RouteParametersConfig {
925+
max_total_routing_fee_msat,
926+
max_total_cltv_expiry_delta: max_total_cltv_expiry_delta
927+
.unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA),
928+
max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT),
929+
max_channel_saturation_power_of_half: max_channel_saturation_power_of_half
930+
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
931+
};
932+
933+
handle_response_result::<_, Bolt12SendRefundResponse>(
934+
client
935+
.bolt12_send_refund(Bolt12SendRefundRequest {
936+
amount_msat: amount.to_msat(),
937+
expiry_secs,
938+
quantity,
939+
payer_note,
940+
route_parameters: Some(route_parameters),
941+
})
942+
.await,
943+
);
944+
},
945+
Commands::Bolt12ReceiveRefund { refund } => {
946+
handle_response_result::<_, Bolt12ReceiveRefundResponse>(
947+
client.bolt12_receive_refund(Bolt12ReceiveRefundRequest { refund }).await,
948+
);
949+
},
875950
Commands::SpontaneousSend {
876951
node_id,
877952
amount,

0 commit comments

Comments
 (0)