Skip to content

Commit 6a048e4

Browse files
authored
Merge pull request #308 from benthecarman/document-reusable-bolt12-offer-cli
Make BOLT12 CLI more ergonomic
2 parents 21a903c + 556adfb commit 6a048e4

7 files changed

Lines changed: 67 additions & 25 deletions

File tree

‎e2e-tests/tests/e2e.rs‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,25 @@ async fn test_cli_bolt12_receive() {
329329
// BOLT12 offers need announced channels for blinded reply paths
330330
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
331331

332-
let output = run_cli(&server_a, &["bolt12-receive", "test offer"]);
332+
let output = run_cli(&server_a, &["bolt12-receive", "-d", "test offer"]);
333333
let offer_str = output["offer"].as_str().unwrap();
334334
assert!(offer_str.starts_with("lno"), "Expected lno prefix, got: {}", offer_str);
335335

336336
let offer: Offer = offer_str.parse().unwrap();
337337
let offer_id = <[u8; 32]>::from_hex(output["offer_id"].as_str().unwrap()).unwrap();
338338
assert_eq!(offer.id().0, offer_id);
339+
340+
let error = server_a
341+
.client()
342+
.bolt12_receive(Bolt12ReceiveRequest {
343+
description: "variable amount".to_string(),
344+
amount_msat: None,
345+
expiry_secs: None,
346+
quantity: Some(3),
347+
})
348+
.await
349+
.unwrap_err();
350+
assert_eq!(error.error_code, InvalidRequestError);
339351
}
340352

341353
#[tokio::test]
@@ -347,7 +359,7 @@ async fn test_cli_decode_offer() {
347359
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
348360

349361
// Create a BOLT12 offer with known parameters
350-
let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]);
362+
let output = run_cli(&server_a, &["bolt12-receive", "-d", "decode offer test"]);
351363
let offer_str = output["offer"].as_str().unwrap();
352364

353365
// Decode it
@@ -378,21 +390,21 @@ async fn test_cli_decode_offer() {
378390
assert!(decoded.get("amount").is_none() || decoded["amount"].is_null());
379391

380392
// Test a fixed-amount offer
381-
let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]);
393+
let output_fixed = run_cli(&server_a, &["bolt12-receive", "50000sat", "-d", "fixed amount"]);
382394
let decoded_fixed =
383395
run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]);
384396
assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000);
385397

386398
// Test that ANSI escape sequences cannot reach the terminal via CLI output.
387399
let desc_with_ansi = "offer\x1b[31m RED \x1b[0m";
388-
let output_ansi = run_cli(&server_a, &["bolt12-receive", desc_with_ansi]);
400+
let output_ansi = run_cli(&server_a, &["bolt12-receive", "-d", desc_with_ansi]);
389401
let raw_decoded =
390402
run_cli_raw(&server_a, &["decode-offer", output_ansi["offer"].as_str().unwrap()]);
391403
assert!(!raw_decoded.contains('\x1b'), "Raw CLI output must not contain ANSI escape bytes");
392404

393405
// Test that Unicode bidi override characters in the description are escaped
394406
let desc_with_bidi = "offer\u{202E}evil";
395-
let output_bidi = run_cli(&server_a, &["bolt12-receive", desc_with_bidi]);
407+
let output_bidi = run_cli(&server_a, &["bolt12-receive", "-d", desc_with_bidi]);
396408
let raw_bidi =
397409
run_cli_raw(&server_a, &["decode-offer", output_bidi["offer"].as_str().unwrap()]);
398410
// LDK exposes offer descriptions through PrintableString, which may replace

‎ldk-server-cli/src/main.rs‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,17 +339,33 @@ enum Commands {
339339
)]
340340
max_channel_saturation_power_of_half: Option<u32>,
341341
},
342-
#[command(about = "Return a BOLT12 offer for receiving payments")]
342+
#[command(
343+
about = "Create a reusable BOLT12 offer for receiving payments",
344+
long_about = "Create a reusable BOLT12 offer for receiving payments.\n\n\
345+
A BOLT12 offer can be paid any number of times, by any number of payers, until it \
346+
expires. Without --expiry-secs the offer never expires, so it can be shared publicly \
347+
(e.g. for donations or a static payment code). Omit the amount to let each payer \
348+
choose how much to send."
349+
)]
343350
Bolt12Receive {
344-
#[arg(help = "Description to attach along with the offer")]
345-
description: String,
346351
#[arg(
347-
help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount offer is returned"
352+
help = "Amount to request per item, e.g. 50sat or 50000msat. If unset, a variable-amount offer is returned"
348353
)]
349354
amount: Option<Amount>,
350-
#[arg(long, help = "Offer expiry time in seconds")]
355+
#[arg(short, long, help = "Description to attach along with the offer")]
356+
description: Option<String>,
357+
#[arg(
358+
short,
359+
long,
360+
help = "Offer expiry time in seconds. If unset, the offer never expires"
361+
)]
351362
expiry_secs: Option<u32>,
352-
#[arg(long, help = "Number of items requested. Can only be set for fixed-amount offers")]
363+
#[arg(
364+
short,
365+
long,
366+
requires = "amount",
367+
help = "Maximum number of items a payer may buy per payment. Only for fixed-amount offers"
368+
)]
353369
quantity: Option<u64>,
354370
},
355371
#[command(about = "Send a payment for a BOLT12 offer")]
@@ -1112,12 +1128,12 @@ async fn main() {
11121128
.await,
11131129
);
11141130
},
1115-
Commands::Bolt12Receive { description, amount, expiry_secs, quantity } => {
1131+
Commands::Bolt12Receive { amount, description, expiry_secs, quantity } => {
11161132
let amount_msat = amount.map(|a| a.to_msat());
11171133
handle_response_result::<_, Bolt12ReceiveResponse>(
11181134
client
11191135
.bolt12_receive(Bolt12ReceiveRequest {
1120-
description,
1136+
description: description.unwrap_or_default(),
11211137
amount_msat,
11221138
expiry_secs,
11231139
quantity,

‎ldk-server-grpc/src/api.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,8 @@ pub struct Bolt11SendUnderpayingResponse {
537537
}
538538
/// Returns a BOLT12 offer for the given amount, if specified.
539539
///
540+
/// The offer is reusable: it can be paid any number of times until it expires.
541+
///
540542
/// See more:
541543
/// - <https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive>
542544
/// - <https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive_variable_amount>
@@ -550,13 +552,15 @@ pub struct Bolt12ReceiveRequest {
550552
/// Will be set in the description field of the encoded offer.
551553
#[prost(string, tag = "1")]
552554
pub description: ::prost::alloc::string::String,
553-
/// The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned.
555+
/// The amount in millisatoshi to request per item. If unset, a "zero-amount" or variable-amount
556+
/// offer is returned.
554557
#[prost(uint64, optional, tag = "2")]
555558
pub amount_msat: ::core::option::Option<u64>,
556-
/// Offer expiry time in seconds.
559+
/// Offer expiry time in seconds. If unset, the offer never expires.
557560
#[prost(uint32, optional, tag = "3")]
558561
pub expiry_secs: ::core::option::Option<u32>,
559-
/// If set, it represents the number of items requested, can only be set for fixed-amount offers.
562+
/// If set, the maximum number of items a payer may request in a single payment. This does not
563+
/// limit how many times the offer can be paid. Can only be set for fixed-amount offers.
560564
#[prost(uint64, optional, tag = "4")]
561565
pub quantity: ::core::option::Option<u64>,
562566
}

‎ldk-server-grpc/src/proto/api.proto‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ message Bolt11SendUnderpayingResponse {
409409

410410
// Returns a BOLT12 offer for the given amount, if specified.
411411
//
412+
// The offer is reusable: it can be paid any number of times until it expires.
413+
//
412414
// See more:
413415
// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive
414416
// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive_variable_amount
@@ -418,13 +420,15 @@ message Bolt12ReceiveRequest {
418420
// Will be set in the description field of the encoded offer.
419421
string description = 1;
420422

421-
// The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned.
423+
// The amount in millisatoshi to request per item. If unset, a "zero-amount" or variable-amount
424+
// offer is returned.
422425
optional uint64 amount_msat = 2;
423426

424-
// Offer expiry time in seconds.
427+
// Offer expiry time in seconds. If unset, the offer never expires.
425428
optional uint32 expiry_secs = 3;
426429

427-
// If set, it represents the number of items requested, can only be set for fixed-amount offers.
430+
// If set, the maximum number of items a payer may request in a single payment. This does not
431+
// limit how many times the offer can be paid. Can only be set for fixed-amount offers.
428432
optional uint64 quantity = 4;
429433
}
430434

‎ldk-server-mcp/src/tools/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ pub fn build_tool_registry() -> ToolRegistry {
193193
),
194194
tool_spec(
195195
"bolt12_receive",
196-
"Create a BOLT12 offer for receiving Lightning payments",
196+
"Create a reusable BOLT12 offer for receiving Lightning payments",
197197
schema::bolt12_receive_schema,
198198
|client, args| Box::pin(handlers::handle_bolt12_receive(client, args)),
199199
),

‎ldk-server-mcp/src/tools/schema.rs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,18 +430,17 @@ pub fn bolt12_receive_schema() -> Value {
430430
},
431431
"amount_msat": {
432432
"type": "integer",
433-
"description": "Amount in millisatoshis. If unset, a variable-amount offer is returned"
433+
"description": "Amount in millisatoshis to request per item. If unset, a variable-amount offer is returned"
434434
},
435435
"expiry_secs": {
436436
"type": "integer",
437-
"description": "Offer expiry time in seconds"
437+
"description": "Offer expiry time in seconds. If unset, the offer never expires"
438438
},
439439
"quantity": {
440440
"type": "integer",
441-
"description": "Number of items requested. Can only be set for fixed-amount offers"
441+
"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"
442442
}
443-
},
444-
"required": ["description"]
443+
}
445444
})
446445
}
447446

‎ldk-server/src/api/bolt12_receive.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use hex::DisplayHex;
1313
use ldk_server_grpc::api::{Bolt12ReceiveRequest, Bolt12ReceiveResponse};
1414

1515
use crate::api::error::LdkServerError;
16+
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
1617
use crate::service::Context;
1718

1819
pub(crate) async fn handle_bolt12_receive_request(
@@ -25,6 +26,12 @@ pub(crate) async fn handle_bolt12_receive_request(
2526
request.expiry_secs,
2627
request.quantity,
2728
)?,
29+
None if request.quantity.is_some() => {
30+
return Err(LdkServerError::new(
31+
InvalidRequestError,
32+
"quantity can only be set for fixed-amount offers".to_string(),
33+
));
34+
},
2835
None => context
2936
.node
3037
.bolt12_payment()

0 commit comments

Comments
 (0)