Skip to content
Open
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
42 changes: 42 additions & 0 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;

Expand Down Expand Up @@ -901,6 +902,47 @@ async fn test_cli_spontaneous_send() {
assert!(matches!(&event_b.event, Some(Event::PaymentReceived(_))));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_cli_spontaneous_send_with_custom_tlvs() {
let bitcoind = TestBitcoind::new();
let server_a = LdkServerHandle::start(&bitcoind).await;
let server_b = LdkServerHandle::start(&bitcoind).await;

let mut events_b = server_b.client().subscribe_events().await.unwrap();

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

// Two odd-type custom TLVs (even types are rejected at the receiver).
let output = run_cli(
&server_a,
&[
"spontaneous-send",
server_b.node_id(),
"10000sat",
"--custom-tlv",
"65537:deadbeef",
"--custom-tlv",
"65539:cafe",
],
);
assert!(!output["payment_id"].as_str().unwrap().is_empty());

// The receiver must observe both TLVs in PaymentReceived.
let event_b =
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await;
let Some(Event::PaymentReceived(pr)) = event_b.event else {
panic!("expected PaymentReceived");
};
assert_eq!(pr.custom_records.len(), 2);
let by_type: HashMap<u64, Vec<u8>> = pr
.custom_records
.into_iter()
.map(|r| (r.type_num, r.value.to_vec()))
.collect();
assert_eq!(by_type.get(&65537).cloned(), Some(vec![0xde, 0xad, 0xbe, 0xef]));
assert_eq!(by_type.get(&65539).cloned(), Some(vec![0xca, 0xfe]));
}

#[tokio::test]
async fn test_cli_get_payment_details() {
let bitcoind = TestBitcoind::new();
Expand Down
62 changes: 59 additions & 3 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::path::PathBuf;

use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use hex_conservative::DisplayHex;
use hex_conservative::{DisplayHex, FromHex};
use ldk_server_client::client::LdkServerClient;
use ldk_server_client::config::{
get_default_config_path, load_config, resolve_api_key, resolve_base_url, resolve_cert_path,
Expand Down Expand Up @@ -45,8 +45,8 @@ use ldk_server_client::ldk_server_grpc::api::{
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
};
use ldk_server_client::ldk_server_grpc::types::{
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
RouteParametersConfig,
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, CustomTlvRecord,
PageToken, RouteParametersConfig,
};
use ldk_server_client::{
DEFAULT_EXPIRY_SECS, DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF, DEFAULT_MAX_PATH_COUNT,
Expand Down Expand Up @@ -314,6 +314,12 @@ enum Commands {
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>,
#[arg(
long = "custom-tlv",
value_parser = parse_custom_tlv,
help = "Custom TLV record to attach, format: <type_num>:<hex_value>. Repeatable. type_num must be >= 65536."
)]
custom_tlvs: Vec<(u64, Vec<u8>)>,
},
#[command(
about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer"
Expand Down Expand Up @@ -810,6 +816,7 @@ async fn main() {
max_total_cltv_expiry_delta,
max_path_count,
max_channel_saturation_power_of_half,
custom_tlvs,
} => {
let amount_msat = amount.to_msat();
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
Expand All @@ -822,12 +829,18 @@ async fn main() {
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
};

let proto_custom_tlvs: Vec<_> = custom_tlvs
.into_iter()
.map(|(type_num, value)| CustomTlvRecord { type_num, value: value.into() })
.collect();

handle_response_result::<_, SpontaneousSendResponse>(
client
.spontaneous_send(SpontaneousSendRequest {
amount_msat,
node_id,
route_parameters: Some(route_parameters),
custom_tlvs: proto_custom_tlvs,
})
.await,
);
Expand Down Expand Up @@ -1249,6 +1262,19 @@ fn parse_page_token(token_str: &str) -> Result<PageToken, LdkServerError> {
Ok(PageToken { token: parts[0].to_string(), index })
}

fn parse_custom_tlv(s: &str) -> Result<(u64, Vec<u8>), String> {
let (type_str, hex_str) =
s.split_once(':').ok_or_else(|| format!("expected <type_num>:<hex_value>, got '{s}'"))?;
let type_num: u64 =
type_str.parse().map_err(|e| format!("invalid type number '{type_str}': {e}"))?;
if type_num < 65536 {
return Err(format!("type number must be >= 65536, got {type_num}"));
}
let value =
Vec::<u8>::from_hex(hex_str).map_err(|e| format!("invalid hex value '{hex_str}': {e}"))?;
Ok((type_num, value))
}

fn handle_error_msg(msg: String) -> ! {
eprintln!("Error: {}", sanitize_for_terminal(msg));
std::process::exit(1);
Expand All @@ -1265,3 +1291,33 @@ fn handle_error(e: LdkServerError) -> ! {
eprintln!("Error ({}): {}", error_type, e.message);
std::process::exit(1); // Exit with status code 1 on error.
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_custom_tlv_accepts_valid_record() {
let (type_num, value) = parse_custom_tlv("65537:deadbeef").unwrap();
assert_eq!(type_num, 65537);
assert_eq!(value, vec![0xde, 0xad, 0xbe, 0xef]);
}

#[test]
fn parse_custom_tlv_rejects_missing_separator() {
let err = parse_custom_tlv("65537").unwrap_err();
assert!(err.contains("expected <type_num>:<hex_value>"));
}

#[test]
fn parse_custom_tlv_rejects_reserved_type() {
let err = parse_custom_tlv("65535:00").unwrap_err();
assert!(err.contains("type number must be >= 65536"));
}

#[test]
fn parse_custom_tlv_rejects_invalid_hex() {
let err = parse_custom_tlv("65537:not-hex").unwrap_err();
assert!(err.contains("invalid hex value"));
}
}
3 changes: 3 additions & 0 deletions ldk-server-grpc/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ pub struct SpontaneousSendRequest {
/// Configuration options for payment routing and pathfinding.
#[prost(message, optional, tag = "3")]
pub route_parameters: ::core::option::Option<super::types::RouteParametersConfig>,
/// Custom TLV records to attach to the outgoing payment.
#[prost(message, repeated, tag = "4")]
pub custom_tlvs: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
}
/// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
Expand Down
6 changes: 6 additions & 0 deletions ldk-server-grpc/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ pub struct PaymentReceived {
/// The payment details for the payment in event.
#[prost(message, optional, tag = "1")]
pub payment: ::core::option::Option<super::types::Payment>,
/// Custom TLV records attached to the incoming payment, if any.
#[prost(message, repeated, tag = "2")]
pub custom_records: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
}
/// PaymentSuccessful indicates a sent payment was successful.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
Expand Down Expand Up @@ -184,6 +187,9 @@ pub struct PaymentClaimable {
/// The payment details for the claimable payment.
#[prost(message, optional, tag = "1")]
pub payment: ::core::option::Option<super::types::Payment>,
/// Custom TLV records attached to the claimable payment, if any.
#[prost(message, repeated, tag = "2")]
pub custom_records: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
}
/// PaymentForwarded indicates a payment was forwarded through the node.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
Expand Down
3 changes: 3 additions & 0 deletions ldk-server-grpc/src/proto/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ message SpontaneousSendRequest {

// Configuration options for payment routing and pathfinding.
optional types.RouteParametersConfig route_parameters = 3;

// Custom TLV records to attach to the outgoing payment.
repeated types.CustomTlvRecord custom_tlvs = 4;
}

// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned.
Expand Down
4 changes: 4 additions & 0 deletions ldk-server-grpc/src/proto/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ message ChannelStateChanged {
message PaymentReceived {
// The payment details for the payment in event.
types.Payment payment = 1;
// Custom TLV records attached to the incoming payment, if any.
repeated types.CustomTlvRecord custom_records = 2;
}

// PaymentSuccessful indicates a sent payment was successful.
Expand All @@ -115,6 +117,8 @@ message PaymentFailed {
message PaymentClaimable {
// The payment details for the claimable payment.
types.Payment payment = 1;
// Custom TLV records attached to the claimable payment, if any.
repeated types.CustomTlvRecord custom_records = 2;
}

// PaymentForwarded indicates a payment was forwarded through the node.
Expand Down
8 changes: 8 additions & 0 deletions ldk-server-grpc/src/proto/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -945,3 +945,11 @@ message Bolt11Feature {
// Whether this feature is known.
bool is_known = 3;
}

// Custom TLV record attached to a payment.
message CustomTlvRecord {
// TLV type number.
uint64 type_num = 1;
// Raw TLV value.
bytes value = 2;
}
14 changes: 14 additions & 0 deletions ldk-server-grpc/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,20 @@ pub struct Bolt11Feature {
#[prost(bool, tag = "3")]
pub is_known: bool,
}
/// Custom TLV record attached to a payment.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "serde", serde(default))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CustomTlvRecord {
/// TLV type number.
#[prost(uint64, tag = "1")]
pub type_num: u64,
/// Raw TLV value.
#[prost(bytes = "bytes", tag = "2")]
pub value: ::prost::bytes::Bytes,
}
/// Represents the direction of a payment.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
Expand Down
51 changes: 50 additions & 1 deletion ldk-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ use std::collections::HashMap;

use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure};
use ldk_node::lightning::routing::router::RouteParametersConfig;
use ldk_node::CustomTlvRecord as NodeCustomTlvRecord;
use ldk_server_grpc::types::channel_config::MaxDustHtlcExposure;
use ldk_server_grpc::types::Bolt11Feature;
use ldk_server_grpc::types::{Bolt11Feature, CustomTlvRecord as ProtoCustomTlvRecord};

use crate::api::error::LdkServerError;
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
Expand Down Expand Up @@ -129,6 +130,14 @@ pub(crate) fn build_route_parameters_config_from_proto(
}
}

pub(crate) fn proto_to_node_custom_tlv(proto: &ProtoCustomTlvRecord) -> NodeCustomTlvRecord {
NodeCustomTlvRecord { type_num: proto.type_num, value: proto.value.to_vec() }
}

pub(crate) fn node_to_proto_custom_tlv(node: &NodeCustomTlvRecord) -> ProtoCustomTlvRecord {
ProtoCustomTlvRecord { type_num: node.type_num, value: node.value.clone().into() }
}

/// Decodes feature flags into a map keyed by bit number. Feature names are derived
/// from LDK's `Features::Display` impl, so they stay in sync automatically.
///
Expand Down Expand Up @@ -182,3 +191,43 @@ fn parse_feature_name(display: &str) -> (&str, bool) {
}
("unknown", false)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn proto_to_node_custom_tlv_preserves_fields() {
let proto =
ProtoCustomTlvRecord { type_num: 65537, value: vec![0xde, 0xad, 0xbe, 0xef].into() };
let node = proto_to_node_custom_tlv(&proto);
assert_eq!(node.type_num, 65537);
assert_eq!(node.value, vec![0xde, 0xad, 0xbe, 0xef]);
}

#[test]
fn node_to_proto_custom_tlv_preserves_fields() {
let node = NodeCustomTlvRecord { type_num: 65537, value: vec![0xde, 0xad, 0xbe, 0xef] };
let proto = node_to_proto_custom_tlv(&node);
assert_eq!(proto.type_num, 65537);
assert_eq!(proto.value.to_vec(), vec![0xde, 0xad, 0xbe, 0xef]);
}

#[test]
fn empty_custom_tlv_value_round_trips() {
let proto = ProtoCustomTlvRecord { type_num: 70000, value: Vec::new().into() };
let node = proto_to_node_custom_tlv(&proto);
let back = node_to_proto_custom_tlv(&node);
assert_eq!(back.type_num, 70000);
assert!(back.value.is_empty());
}

#[test]
fn non_empty_custom_tlv_value_round_trips() {
let proto = ProtoCustomTlvRecord { type_num: 70001, value: vec![1, 2, 3, 4].into() };
let node = proto_to_node_custom_tlv(&proto);
let back = node_to_proto_custom_tlv(&node);
assert_eq!(back.type_num, 70001);
assert_eq!(back.value.to_vec(), vec![1, 2, 3, 4]);
}
}
16 changes: 13 additions & 3 deletions ldk-server/src/api/spontaneous_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ use std::sync::Arc;
use ldk_node::bitcoin::secp256k1::PublicKey;
use ldk_server_grpc::api::{SpontaneousSendRequest, SpontaneousSendResponse};

use crate::api::build_route_parameters_config_from_proto;
use crate::api::error::LdkServerError;
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
use crate::api::{build_route_parameters_config_from_proto, proto_to_node_custom_tlv};
use crate::service::Context;

pub(crate) async fn handle_spontaneous_send_request(
Expand All @@ -27,8 +27,18 @@ pub(crate) async fn handle_spontaneous_send_request(

let route_parameters = build_route_parameters_config_from_proto(request.route_parameters)?;

let payment_id =
context.node.spontaneous_payment().send(request.amount_msat, node_id, route_parameters)?;
let payment_id = if request.custom_tlvs.is_empty() {
context.node.spontaneous_payment().send(request.amount_msat, node_id, route_parameters)?
} else {
let custom_tlvs: Vec<_> =
request.custom_tlvs.iter().map(proto_to_node_custom_tlv).collect();
context.node.spontaneous_payment().send_with_custom_tlvs(
request.amount_msat,
node_id,
route_parameters,
custom_tlvs,
)?
};

let response = SpontaneousSendResponse { payment_id: payment_id.to_string() };
Ok(response)
Expand Down
Loading