Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,14 @@ when the invoice is paid.
| `Bolt11ReceiveViaJitChannel` | Create a fixed-amount invoice with JIT channel opening |
| `Bolt11ReceiveVariableAmountViaJitChannel` | Create a variable-amount invoice with JIT channel opening |

### BOLT12 Offers

| RPC | Description |
|-----------------|-------------------------------------------------------------------------|
| `Bolt12Receive` | Create a BOLT12 offer (fixed or variable amount) |
| `Bolt12Send` | Pay a BOLT12 offer (with optional quantity, payer note, routing config) |
### BOLT12 Offers and Refunds

| RPC | Description |
|-----------------------|-------------------------------------------------------------------------|
| `Bolt12Receive` | Create a BOLT12 offer (fixed or variable amount) |
| `Bolt12Send` | Pay a BOLT12 offer (with optional quantity, payer note, routing config) |
| `Bolt12SendRefund` | Create a BOLT12 refund that this node will pay |
| `Bolt12ReceiveRefund` | Request an incoming payment for a BOLT12 refund |

### Spontaneous and Unified Send

Expand Down
22 changes: 21 additions & 1 deletion e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ use std::time::Duration;

use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use ldk_server_client::client::{EventStream, LdkServerClient};
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
use ldk_server_client::ldk_server_grpc::events::EventEnvelope;
use ldk_server_grpc::api::{
open_channel_request, GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest,
OpenChannelRequest,
};
use serde_json::Value;

const EVENT_TIMEOUT: Duration = Duration::from_secs(15);

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
pub bitcoind: Node,
Expand Down Expand Up @@ -487,6 +491,22 @@ pub async fn wait_for_file(path: &Path, timeout: Duration) {
}
}

/// Wait for the next event that matches the predicate.
pub async fn wait_for_event(
events: &mut EventStream, pred: impl Fn(&Event) -> bool,
) -> EventEnvelope {
tokio::time::timeout(EVENT_TIMEOUT, async {
while let Some(Ok(event)) = events.next_message().await {
if event.event.as_ref().is_some_and(&pred) {
return event;
}
}
panic!("Event stream ended without matching event");
})
.await
.expect("Timed out waiting for event")
}

/// Poll get_node_info until the server responds successfully.
async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse {
let start = std::time::Instant::now();
Expand Down
72 changes: 53 additions & 19 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,28 @@ use std::time::Duration;

use e2e_tests::{
find_available_port, mine_and_sync, run_cli, run_cli_raw, run_cli_with_config,
setup_funded_channel, wait_for_onchain_balance, wait_for_usable_channel, LdkServerConfig,
LdkServerHandle, TestBitcoind,
setup_funded_channel, wait_for_event, wait_for_onchain_balance, wait_for_usable_channel,
LdkServerConfig, LdkServerHandle, TestBitcoind,
};
use hex_conservative::{DisplayHex, FromHex};
use ldk_node::bitcoin::hashes::{sha256, Hash};
use ldk_node::lightning::ln::msgs::SocketAddress;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning::offers::refund::Refund;
use ldk_node::lightning_invoice::Bolt11Invoice;
use ldk_server_client::client::EventStream;
use ldk_server_client::ldk_server_grpc::api::{
open_channel_request, Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetBalancesRequest,
OnchainReceiveRequest, OpenChannelRequest,
};
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
use ldk_server_client::ldk_server_grpc::events::{
ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, EventEnvelope,
ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind,
};
use ldk_server_client::ldk_server_grpc::types::{
bolt11_invoice_description, Bolt11InvoiceDescription,
};
use ldk_server_grpc::types::payment_kind;

const EVENT_TIMEOUT: Duration = Duration::from_secs(15);

async fn wait_for_event(events: &mut EventStream, pred: impl Fn(&Event) -> bool) -> EventEnvelope {
tokio::time::timeout(EVENT_TIMEOUT, async {
while let Some(Ok(ev)) = events.next_message().await {
if ev.event.as_ref().is_some_and(&pred) {
return ev;
}
}
panic!("Event stream ended without matching event");
})
.await
.expect("Timed out waiting for event")
}

#[tokio::test]
async fn test_cli_get_node_info() {
let bitcoind = TestBitcoind::new();
Expand Down Expand Up @@ -987,6 +972,55 @@ async fn test_cli_bolt12_send() {
assert!(!output["payment_id"].as_str().unwrap().is_empty());
}

#[tokio::test]
async fn test_cli_bolt12_refund() {
let bitcoind = TestBitcoind::new();
let server_a = LdkServerHandle::start(&bitcoind).await;
let server_b = LdkServerHandle::start(&bitcoind).await;
let mut events_a = server_a.client().subscribe_events().await.unwrap();
let mut events_b = server_b.client().subscribe_events().await.unwrap();
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;

// Give B outbound liquidity for the refund payment.
let offer = server_b
.client()
.bolt12_receive(Bolt12ReceiveRequest {
description: "refund funding payment".to_string(),
amount_msat: Some(10_000_000),
expiry_secs: None,
quantity: None,
})
.await
.unwrap();
run_cli(&server_a, &["bolt12-send", &offer.offer]);
wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentSuccessful(_))).await;
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await;

let output = run_cli(
&server_b,
&["bolt12-send-refund", "5000sat", "--quantity", "1", "--payer-note", "test refund"],
);
let refund_str = output["refund"].as_str().unwrap();
assert!(refund_str.starts_with("lnr"), "Expected lnr prefix, got: {refund_str}");
let refund = Refund::from_str(refund_str).unwrap();
assert_eq!(refund.amount_msats(), 5_000_000);
assert_eq!(refund.quantity(), Some(1));
assert_eq!(refund.payer_note().unwrap().to_string(), "test refund");

let output = run_cli(&server_a, &["bolt12-receive-refund", refund_str]);
let payment_hash = output["payment_hash"].as_str().unwrap();
let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentReceived(_))).await;
let Some(Event::PaymentReceived(payment_received)) = event_a.event else {
panic!("expected PaymentReceived");
};
let payment = payment_received.payment.unwrap();
let Some(payment_kind::Kind::Bolt12Refund(refund)) = payment.kind.unwrap().kind else {
panic!("expected BOLT12 refund kind");
};
assert_eq!(refund.hash.as_deref(), Some(payment_hash));
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentSuccessful(_))).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_cli_spontaneous_send() {
let bitcoind = TestBitcoind::new();
Expand Down
81 changes: 70 additions & 11 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use e2e_tests::{LdkServerHandle, McpHandle, TestBitcoind};
use std::str::FromStr;

use e2e_tests::{setup_funded_channel, wait_for_event, LdkServerHandle, McpHandle, TestBitcoind};
use ldk_node::lightning::offers::refund::Refund;
use ldk_server_client::ldk_server_grpc::api::Bolt11ReceiveRequest;
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
use ldk_server_client::ldk_server_grpc::types::{
bolt11_invoice_description, Bolt11InvoiceDescription,
bolt11_invoice_description, payment_kind, Bolt11InvoiceDescription,
};
use serde_json::json;
use serde_json::{json, Value};

fn tool_result_json(response: &Value) -> Value {
let text = response["result"]["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
}
Comment on lines +21 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be used in a few other pre-existing places. Consider adding another commit to do so.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added


#[tokio::test]
async fn test_mcp_initialize_and_list_tools() {
Expand Down Expand Up @@ -49,17 +58,14 @@ async fn test_mcp_live_tool_calls() {
"name": "get_node_info",
"arguments": {}
}));
let node_info_text = node_info["result"]["content"][0]["text"].as_str().unwrap();
let node_info_json: serde_json::Value = serde_json::from_str(node_info_text).unwrap();
let node_info_json = tool_result_json(&node_info);
assert_eq!(node_info_json["node_id"], server.node_id());

let onchain_receive = mcp.call(2, "tools/call", json!({
"name": "onchain_receive",
"arguments": {}
}));
let onchain_receive_text = onchain_receive["result"]["content"][0]["text"].as_str().unwrap();
let onchain_receive_json: serde_json::Value =
serde_json::from_str(onchain_receive_text).unwrap();
let onchain_receive_json = tool_result_json(&onchain_receive);
assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1"));

let invoice = server
Expand All @@ -78,10 +84,63 @@ async fn test_mcp_live_tool_calls() {
"name": "decode_invoice",
"arguments": { "invoice": invoice.invoice }
}));
let decode_invoice_text = decode_invoice["result"]["content"][0]["text"].as_str().unwrap();
let decode_invoice_json: serde_json::Value =
serde_json::from_str(decode_invoice_text).unwrap();
let decode_invoice_json = tool_result_json(&decode_invoice);
assert_eq!(decode_invoice_json["destination"], server.node_id());
assert_eq!(decode_invoice_json["description"], "mcp decode");
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mcp_bolt12_refund() {
let bitcoind = TestBitcoind::new();
let server_a = LdkServerHandle::start(&bitcoind).await;
let server_b = LdkServerHandle::start(&bitcoind).await;
let mut events_a = server_a.client().subscribe_events().await.unwrap();
let mut events_b = server_b.client().subscribe_events().await.unwrap();

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

let mut mcp_a = McpHandle::start(&server_a);
let mut mcp_b = McpHandle::start(&server_b);
let send_refund = mcp_b.call(
1,
"tools/call",
json!({
"name": "bolt12_send_refund",
"arguments": {
"amount_msat": 5_000_000,
"quantity": 1,
"payer_note": "mcp refund"
}
}),
);
let send_refund = tool_result_json(&send_refund);
let refund_str = send_refund["refund"].as_str().unwrap();
let refund = Refund::from_str(refund_str).unwrap();
assert_eq!(refund.amount_msats(), 5_000_000);
assert_eq!(refund.quantity(), Some(1));
assert_eq!(refund.payer_note().unwrap().to_string(), "mcp refund");

let receive_refund = mcp_a.call(
1,
"tools/call",
json!({
"name": "bolt12_receive_refund",
"arguments": { "refund": refund_str }
}),
);
let receive_refund = tool_result_json(&receive_refund);
let payment_hash = receive_refund["payment_hash"].as_str().unwrap();

let event_a =
wait_for_event(&mut events_a, |event| matches!(event, Event::PaymentReceived(_))).await;
let Some(Event::PaymentReceived(payment_received)) = event_a.event else {
panic!("expected PaymentReceived");
};
let payment = payment_received.payment.unwrap();
let Some(payment_kind::Kind::Bolt12Refund(refund)) = payment.kind.unwrap().kind else {
panic!("expected BOLT12 refund kind");
};
assert_eq!(refund.hash.as_deref(), Some(payment_hash));
wait_for_event(&mut events_b, |event| matches!(event, Event::PaymentSuccessful(_))).await;
}
89 changes: 82 additions & 7 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ use ldk_server_client::ldk_server_grpc::api::{
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11ReceiveVariableAmountViaJitChannelRequest,
Bolt11ReceiveVariableAmountViaJitChannelResponse, Bolt11ReceiveViaJitChannelRequest,
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12ReceiveRequest,
Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest,
CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, DecodeInvoiceRequest,
DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest,
DisconnectPeerResponse, ExportPathfindingScoresRequest, ForceCloseChannelRequest,
ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest,
GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12ReceiveRefundRequest,
Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse,
Bolt12SendRefundRequest, Bolt12SendRefundResponse, Bolt12SendRequest, Bolt12SendResponse,
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
Expand Down Expand Up @@ -319,6 +320,43 @@ enum Commands {
)]
max_channel_saturation_power_of_half: Option<u32>,
},
#[command(about = "Create a BOLT12 refund")]
Bolt12SendRefund {
#[arg(help = "Amount to refund, e.g. 50sat or 50000msat")]
amount: Amount,
#[arg(long, default_value_t = DEFAULT_EXPIRY_SECS, help = "Refund expiry time in seconds")]
expiry_secs: u32,
#[arg(short, long, help = "Number of items being refunded")]
quantity: Option<u64>,
#[arg(
short,
long,
help = "Note to include for the recipient. Will be reflected back in the invoice"
)]
payer_note: Option<String>,
#[arg(
long,
help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of the payment amount + 50 sats"
)]
max_total_routing_fee: Option<Amount>,
#[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")]
max_total_cltv_expiry_delta: Option<u32>,
#[arg(
long,
help = "Maximum number of paths that may be used by MPP payments (default: 10)"
)]
max_path_count: Option<u32>,
#[arg(
long,
help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)"
)]
max_channel_saturation_power_of_half: Option<u32>,
},
#[command(about = "Request payment for a BOLT12 refund")]
Bolt12ReceiveRefund {
#[arg(help = "A BOLT12 refund from the node that will send the payment")]
refund: String,
},
#[command(about = "Send a spontaneous payment (keysend) to a node")]
SpontaneousSend {
#[arg(help = "The hex-encoded public key of the node to send the payment to")]
Expand Down Expand Up @@ -872,6 +910,43 @@ async fn main() {
.await,
);
},
Commands::Bolt12SendRefund {
amount,
expiry_secs,
quantity,
payer_note,
max_total_routing_fee,
max_total_cltv_expiry_delta,
max_path_count,
max_channel_saturation_power_of_half,
} => {
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
let route_parameters = RouteParametersConfig {
max_total_routing_fee_msat,
max_total_cltv_expiry_delta: max_total_cltv_expiry_delta
.unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA),
max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT),
max_channel_saturation_power_of_half: max_channel_saturation_power_of_half
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
};

handle_response_result::<_, Bolt12SendRefundResponse>(
client
.bolt12_send_refund(Bolt12SendRefundRequest {
amount_msat: amount.to_msat(),
expiry_secs,
quantity,
payer_note,
route_parameters: Some(route_parameters),
})
.await,
);
},
Commands::Bolt12ReceiveRefund { refund } => {
handle_response_result::<_, Bolt12ReceiveRefundResponse>(
client.bolt12_receive_refund(Bolt12ReceiveRefundRequest { refund }).await,
);
},
Commands::SpontaneousSend {
node_id,
amount,
Expand Down
Loading