From e8e80457f47dc4c149dd4f5e20bc2ba278662916 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 9 Oct 2026 16:10:54 -0500 Subject: [PATCH 1/2] Make BOLT12 CLI more ergonomic It was unclear how to create a reusable offer, and `bolt12-receive 1000sat` silently used the amount as the description. Take the amount positionally with an optional -d description, like bolt11-receive, and document that offers can be paid repeatedly until they expire. --- e2e-tests/tests/e2e.rs | 10 ++++----- ldk-server-cli/src/main.rs | 32 +++++++++++++++++++++-------- ldk-server-grpc/src/api.rs | 10 ++++++--- ldk-server-grpc/src/proto/api.proto | 10 ++++++--- ldk-server-mcp/src/tools/mod.rs | 2 +- ldk-server-mcp/src/tools/schema.rs | 9 ++++---- 6 files changed, 48 insertions(+), 25 deletions(-) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 41b30435..bf29911c 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -329,7 +329,7 @@ async fn test_cli_bolt12_receive() { // BOLT12 offers need announced channels for blinded reply paths setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; - let output = run_cli(&server_a, &["bolt12-receive", "test offer"]); + let output = run_cli(&server_a, &["bolt12-receive", "-d", "test offer"]); let offer_str = output["offer"].as_str().unwrap(); assert!(offer_str.starts_with("lno"), "Expected lno prefix, got: {}", offer_str); @@ -347,7 +347,7 @@ async fn test_cli_decode_offer() { setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; // Create a BOLT12 offer with known parameters - let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]); + let output = run_cli(&server_a, &["bolt12-receive", "-d", "decode offer test"]); let offer_str = output["offer"].as_str().unwrap(); // Decode it @@ -378,21 +378,21 @@ async fn test_cli_decode_offer() { assert!(decoded.get("amount").is_none() || decoded["amount"].is_null()); // Test a fixed-amount offer - let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]); + let output_fixed = run_cli(&server_a, &["bolt12-receive", "50000sat", "-d", "fixed amount"]); let decoded_fixed = run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]); assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000); // Test that ANSI escape sequences cannot reach the terminal via CLI output. let desc_with_ansi = "offer\x1b[31m RED \x1b[0m"; - let output_ansi = run_cli(&server_a, &["bolt12-receive", desc_with_ansi]); + let output_ansi = run_cli(&server_a, &["bolt12-receive", "-d", desc_with_ansi]); let raw_decoded = run_cli_raw(&server_a, &["decode-offer", output_ansi["offer"].as_str().unwrap()]); assert!(!raw_decoded.contains('\x1b'), "Raw CLI output must not contain ANSI escape bytes"); // Test that Unicode bidi override characters in the description are escaped let desc_with_bidi = "offer\u{202E}evil"; - let output_bidi = run_cli(&server_a, &["bolt12-receive", desc_with_bidi]); + let output_bidi = run_cli(&server_a, &["bolt12-receive", "-d", desc_with_bidi]); let raw_bidi = run_cli_raw(&server_a, &["decode-offer", output_bidi["offer"].as_str().unwrap()]); // LDK exposes offer descriptions through PrintableString, which may replace diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index c1d7e6b4..0c912721 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -339,17 +339,33 @@ enum Commands { )] max_channel_saturation_power_of_half: Option, }, - #[command(about = "Return a BOLT12 offer for receiving payments")] + #[command( + about = "Create a reusable BOLT12 offer for receiving payments", + long_about = "Create a reusable BOLT12 offer for receiving payments.\n\n\ + A BOLT12 offer can be paid any number of times, by any number of payers, until it \ + expires. Without --expiry-secs the offer never expires, so it can be shared publicly \ + (e.g. for donations or a static payment code). Omit the amount to let each payer \ + choose how much to send." + )] Bolt12Receive { - #[arg(help = "Description to attach along with the offer")] - description: String, #[arg( - help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount offer is returned" + help = "Amount to request per item, e.g. 50sat or 50000msat. If unset, a variable-amount offer is returned" )] amount: Option, - #[arg(long, help = "Offer expiry time in seconds")] + #[arg(short, long, help = "Description to attach along with the offer")] + description: Option, + #[arg( + short, + long, + help = "Offer expiry time in seconds. If unset, the offer never expires" + )] expiry_secs: Option, - #[arg(long, help = "Number of items requested. Can only be set for fixed-amount offers")] + #[arg( + short, + long, + requires = "amount", + help = "Maximum number of items a payer may buy per payment. Only for fixed-amount offers" + )] quantity: Option, }, #[command(about = "Send a payment for a BOLT12 offer")] @@ -1112,12 +1128,12 @@ async fn main() { .await, ); }, - Commands::Bolt12Receive { description, amount, expiry_secs, quantity } => { + Commands::Bolt12Receive { amount, description, expiry_secs, quantity } => { let amount_msat = amount.map(|a| a.to_msat()); handle_response_result::<_, Bolt12ReceiveResponse>( client .bolt12_receive(Bolt12ReceiveRequest { - description, + description: description.unwrap_or_default(), amount_msat, expiry_secs, quantity, diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 99f0cfba..50d987a3 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -537,6 +537,8 @@ pub struct Bolt11SendUnderpayingResponse { } /// Returns a BOLT12 offer for the given amount, if specified. /// +/// The offer is reusable: it can be paid any number of times until it expires. +/// /// See more: /// - /// - @@ -550,13 +552,15 @@ pub struct Bolt12ReceiveRequest { /// Will be set in the description field of the encoded offer. #[prost(string, tag = "1")] pub description: ::prost::alloc::string::String, - /// The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned. + /// The amount in millisatoshi to request per item. If unset, a "zero-amount" or variable-amount + /// offer is returned. #[prost(uint64, optional, tag = "2")] pub amount_msat: ::core::option::Option, - /// Offer expiry time in seconds. + /// Offer expiry time in seconds. If unset, the offer never expires. #[prost(uint32, optional, tag = "3")] pub expiry_secs: ::core::option::Option, - /// If set, it represents the number of items requested, can only be set for fixed-amount offers. + /// If set, the maximum number of items a payer may request in a single payment. This does not + /// limit how many times the offer can be paid. Can only be set for fixed-amount offers. #[prost(uint64, optional, tag = "4")] pub quantity: ::core::option::Option, } diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index 8580162d..7d8fbc5f 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -409,6 +409,8 @@ message Bolt11SendUnderpayingResponse { // Returns a BOLT12 offer for the given amount, if specified. // +// The offer is reusable: it can be paid any number of times until it expires. +// // See more: // - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive // - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive_variable_amount @@ -418,13 +420,15 @@ message Bolt12ReceiveRequest { // Will be set in the description field of the encoded offer. string description = 1; - // The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned. + // The amount in millisatoshi to request per item. If unset, a "zero-amount" or variable-amount + // offer is returned. optional uint64 amount_msat = 2; - // Offer expiry time in seconds. + // Offer expiry time in seconds. If unset, the offer never expires. optional uint32 expiry_secs = 3; - // If set, it represents the number of items requested, can only be set for fixed-amount offers. + // If set, the maximum number of items a payer may request in a single payment. This does not + // limit how many times the offer can be paid. Can only be set for fixed-amount offers. optional uint64 quantity = 4; } diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index d7c17531..49f9422b 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -193,7 +193,7 @@ pub fn build_tool_registry() -> ToolRegistry { ), tool_spec( "bolt12_receive", - "Create a BOLT12 offer for receiving Lightning payments", + "Create a reusable BOLT12 offer for receiving Lightning payments", schema::bolt12_receive_schema, |client, args| Box::pin(handlers::handle_bolt12_receive(client, args)), ), diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index 994684fa..ee90a530 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -430,18 +430,17 @@ pub fn bolt12_receive_schema() -> Value { }, "amount_msat": { "type": "integer", - "description": "Amount in millisatoshis. If unset, a variable-amount offer is returned" + "description": "Amount in millisatoshis to request per item. If unset, a variable-amount offer is returned" }, "expiry_secs": { "type": "integer", - "description": "Offer expiry time in seconds" + "description": "Offer expiry time in seconds. If unset, the offer never expires" }, "quantity": { "type": "integer", - "description": "Number of items requested. Can only be set for fixed-amount offers" + "description": "Maximum number of items a payer may request in a single payment. Does not limit how many times the offer can be paid. Can only be set for fixed-amount offers" } - }, - "required": ["description"] + } }) } From 556adfbed15f57d3fa07d3954bc7327ea05fa314 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 9 Oct 2026 16:44:40 -0500 Subject: [PATCH 2/2] Reject quantity on variable-amount offers The variable-amount path silently dropped quantity, so callers got an offer that didn't match what they asked for. Return an invalid request error instead. --- e2e-tests/tests/e2e.rs | 12 ++++++++++++ ldk-server/src/api/bolt12_receive.rs | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index bf29911c..157f2e8c 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -336,6 +336,18 @@ async fn test_cli_bolt12_receive() { let offer: Offer = offer_str.parse().unwrap(); let offer_id = <[u8; 32]>::from_hex(output["offer_id"].as_str().unwrap()).unwrap(); assert_eq!(offer.id().0, offer_id); + + let error = server_a + .client() + .bolt12_receive(Bolt12ReceiveRequest { + description: "variable amount".to_string(), + amount_msat: None, + expiry_secs: None, + quantity: Some(3), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); } #[tokio::test] diff --git a/ldk-server/src/api/bolt12_receive.rs b/ldk-server/src/api/bolt12_receive.rs index 6558a6d8..cc05df4a 100644 --- a/ldk-server/src/api/bolt12_receive.rs +++ b/ldk-server/src/api/bolt12_receive.rs @@ -13,6 +13,7 @@ use hex::DisplayHex; use ldk_server_grpc::api::{Bolt12ReceiveRequest, Bolt12ReceiveResponse}; use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; use crate::service::Context; pub(crate) async fn handle_bolt12_receive_request( @@ -25,6 +26,12 @@ pub(crate) async fn handle_bolt12_receive_request( request.expiry_secs, request.quantity, )?, + None if request.quantity.is_some() => { + return Err(LdkServerError::new( + InvalidRequestError, + "quantity can only be set for fixed-amount offers".to_string(), + )); + }, None => context .node .bolt12_payment()