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
22 changes: 17 additions & 5 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,25 @@ 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);

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]
Expand All @@ -347,7 +359,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
Expand Down Expand Up @@ -378,21 +390,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
Expand Down
32 changes: 24 additions & 8 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,17 +339,33 @@ enum Commands {
)]
max_channel_saturation_power_of_half: Option<u32>,
},
#[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<Amount>,
#[arg(long, help = "Offer expiry time in seconds")]
#[arg(short, long, help = "Description to attach along with the offer")]
description: Option<String>,
#[arg(
short,
long,
help = "Offer expiry time in seconds. If unset, the offer never expires"
)]
expiry_secs: Option<u32>,
#[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<u64>,
},
#[command(about = "Send a payment for a BOLT12 offer")]
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
/// - <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>
Expand All @@ -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<u64>,
/// 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<u32>,
/// 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<u64>,
}
Expand Down
10 changes: 7 additions & 3 deletions ldk-server-grpc/src/proto/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion ldk-server-mcp/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
),
Expand Down
9 changes: 4 additions & 5 deletions ldk-server-mcp/src/tools/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
})
}

Expand Down
7 changes: 7 additions & 0 deletions ldk-server/src/api/bolt12_receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down
Loading