diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 0fb634a8..e089902b 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -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; @@ -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> = 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(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 94a78644..3aa6f1c8 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -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, @@ -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, @@ -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, + #[arg( + long = "custom-tlv", + value_parser = parse_custom_tlv, + help = "Custom TLV record to attach, format: :. Repeatable. type_num must be >= 65536." + )] + custom_tlvs: Vec<(u64, Vec)>, }, #[command( about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer" @@ -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()); @@ -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, ); @@ -1249,6 +1262,19 @@ fn parse_page_token(token_str: &str) -> Result { Ok(PageToken { token: parts[0].to_string(), index }) } +fn parse_custom_tlv(s: &str) -> Result<(u64, Vec), String> { + let (type_str, hex_str) = + s.split_once(':').ok_or_else(|| format!("expected :, 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::::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); @@ -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 :")); + } + + #[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")); + } +} diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 07f41e1f..b4eda4ed 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -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, + /// Custom TLV records to attach to the outgoing payment. + #[prost(message, repeated, tag = "4")] + pub custom_tlvs: ::prost::alloc::vec::Vec, } /// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-grpc/src/events.rs b/ldk-server-grpc/src/events.rs index 8fa2235b..805eea83 100644 --- a/ldk-server-grpc/src/events.rs +++ b/ldk-server-grpc/src/events.rs @@ -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, + /// Custom TLV records attached to the incoming payment, if any. + #[prost(message, repeated, tag = "2")] + pub custom_records: ::prost::alloc::vec::Vec, } /// PaymentSuccessful indicates a sent payment was successful. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -184,6 +187,9 @@ pub struct PaymentClaimable { /// The payment details for the claimable payment. #[prost(message, optional, tag = "1")] pub payment: ::core::option::Option, + /// Custom TLV records attached to the claimable payment, if any. + #[prost(message, repeated, tag = "2")] + pub custom_records: ::prost::alloc::vec::Vec, } /// PaymentForwarded indicates a payment was forwarded through the node. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index ba6e5a1e..516c406b 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -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. diff --git a/ldk-server-grpc/src/proto/events.proto b/ldk-server-grpc/src/proto/events.proto index 97524fc4..3ae962a6 100644 --- a/ldk-server-grpc/src/proto/events.proto +++ b/ldk-server-grpc/src/proto/events.proto @@ -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. @@ -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. diff --git a/ldk-server-grpc/src/proto/types.proto b/ldk-server-grpc/src/proto/types.proto index 83be8be8..f753d9d5 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -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; +} diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index 67e52ca8..40e35667 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -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"))] diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index ec0a03f6..7163c53a 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -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; @@ -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. /// @@ -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]); + } +} diff --git a/ldk-server/src/api/spontaneous_send.rs b/ldk-server/src/api/spontaneous_send.rs index 507d3882..60ed6594 100644 --- a/ldk-server/src/api/spontaneous_send.rs +++ b/ldk-server/src/api/spontaneous_send.rs @@ -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( @@ -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) diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index a5235496..d8cfc96c 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -30,6 +30,7 @@ use ldk_node::entropy::NodeEntropy; use ldk_node::lightning::events::ClosureReason; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::ln::types::ChannelId; +use ldk_node::CustomTlvRecord; use ldk_node::{Builder, Event, Node}; use ldk_server_grpc::events; use ldk_server_grpc::events::{event_envelope, EventEnvelope}; @@ -41,6 +42,7 @@ use tokio::select; use tokio::signal::unix::SignalKind; use tokio::sync::broadcast; +use crate::api::node_to_proto_custom_tlv; use crate::io::persist::paginated_kv_store::PaginatedKVStore; use crate::io::persist::sqlite_store::SqliteStore; use crate::io::persist::{ @@ -446,20 +448,36 @@ fn main() { metrics.update_channels_count(true); } } - Event::PaymentReceived { payment_id, payment_hash, amount_msat, .. } => { + Event::PaymentReceived { + payment_id, + payment_hash, + amount_msat, + custom_records, + .. + } => { info!( "PAYMENT_RECEIVED: with id {:?}, hash {}, amount_msat {}", payment_id, payment_hash, amount_msat ); let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1"); - send_event_and_upsert_payment(&payment_id, - |payment_ref| event_envelope::Event::PaymentReceived(events::PaymentReceived { - payment: Some(payment_ref.clone()), - }), + let proto_custom_records: Vec<_> = custom_records + .iter() + .map(node_to_proto_custom_tlv) + .collect(); + + send_event_and_upsert_payment( + &payment_id, + move |payment_ref| { + event_envelope::Event::PaymentReceived(events::PaymentReceived { + payment: Some(payment_ref.clone()), + custom_records: proto_custom_records, + }) + }, &event_node, &event_sender, - Arc::clone(&paginated_store)); + Arc::clone(&paginated_store), + ); if let Some(metrics) = &metrics { metrics.update_all_balances(&event_node); @@ -496,14 +514,18 @@ fn main() { metrics.update_payments_count(false); } }, - Event::PaymentClaimable {payment_id, ..} => { - send_event_and_upsert_payment(&payment_id, - |payment_ref| event_envelope::Event::PaymentClaimable(events::PaymentClaimable { - payment: Some(payment_ref.clone()), - }), + Event::PaymentClaimable { payment_id, custom_records, .. } => { + send_event_and_upsert_payment( + &payment_id, + |payment_ref| { + event_envelope::Event::PaymentClaimable( + build_payment_claimable_proto(payment_ref, &custom_records), + ) + }, &event_node, &event_sender, - Arc::clone(&paginated_store)); + Arc::clone(&paginated_store), + ); }, Event::PaymentForwarded { prev_channel_id, @@ -623,7 +645,7 @@ fn main() { } fn send_event_and_upsert_payment( - payment_id: &PaymentId, payment_to_event: fn(&Payment) -> event_envelope::Event, + payment_id: &PaymentId, payment_to_event: impl FnOnce(&Payment) -> event_envelope::Event, event_node: &Node, event_sender: &broadcast::Sender, paginated_store: Arc, ) { @@ -840,6 +862,17 @@ fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result { } } +fn build_payment_claimable_proto( + payment_ref: &Payment, custom_records: &[CustomTlvRecord], +) -> events::PaymentClaimable { + let proto_custom_records: Vec<_> = + custom_records.iter().map(node_to_proto_custom_tlv).collect(); + events::PaymentClaimable { + payment: Some(payment_ref.clone()), + custom_records: proto_custom_records, + } +} + #[cfg(test)] mod tests { use ldk_server_grpc::events::channel_state_change_reason::Details; @@ -940,4 +973,19 @@ mod tests { assert_eq!(proto.kind, events::ChannelStateChangeReasonKind::FundingTimedOut as i32); assert!(proto.details.is_none()); } + + #[test] + fn payment_claimable_proto_contains_custom_records() { + let payment = ldk_server_grpc::types::Payment::default(); + let records = vec![ + CustomTlvRecord { type_num: 65537, value: vec![1, 2, 3] }, + CustomTlvRecord { type_num: 65538, value: Vec::new() }, + ]; + let proto = build_payment_claimable_proto(&payment, &records); + assert_eq!(proto.custom_records.len(), 2); + assert_eq!(proto.custom_records[0].type_num, 65537); + assert_eq!(proto.custom_records[0].value.to_vec(), vec![1, 2, 3]); + assert_eq!(proto.custom_records[1].type_num, 65538); + assert!(proto.custom_records[1].value.to_vec().is_empty()); + } }