Skip to content

Commit 35ba194

Browse files
committed
Expose custom TLVs on spontaneous send and payment events
1 parent 1e9e9ba commit 35ba194

11 files changed

Lines changed: 263 additions & 20 deletions

File tree

e2e-tests/tests/e2e.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10+
use std::collections::HashMap;
1011
use std::str::FromStr;
1112
use std::time::Duration;
1213

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

905+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
906+
async fn test_cli_spontaneous_send_with_custom_tlvs() {
907+
let bitcoind = TestBitcoind::new();
908+
let server_a = LdkServerHandle::start(&bitcoind).await;
909+
let server_b = LdkServerHandle::start(&bitcoind).await;
910+
911+
let mut events_b = server_b.client().subscribe_events().await.unwrap();
912+
913+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
914+
915+
// Two odd-type custom TLVs (even types are rejected at the receiver).
916+
let output = run_cli(
917+
&server_a,
918+
&[
919+
"spontaneous-send",
920+
server_b.node_id(),
921+
"10000sat",
922+
"--custom-tlv",
923+
"65537:deadbeef",
924+
"--custom-tlv",
925+
"65539:cafe",
926+
],
927+
);
928+
assert!(!output["payment_id"].as_str().unwrap().is_empty());
929+
930+
// The receiver must observe both TLVs in PaymentReceived.
931+
let event_b =
932+
wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await;
933+
let Some(Event::PaymentReceived(pr)) = event_b.event else {
934+
panic!("expected PaymentReceived");
935+
};
936+
assert_eq!(pr.custom_records.len(), 2);
937+
let by_type: HashMap<u64, Vec<u8>> = pr
938+
.custom_records
939+
.into_iter()
940+
.map(|r| (r.type_num, r.value.to_vec()))
941+
.collect();
942+
assert_eq!(by_type.get(&65537).cloned(), Some(vec![0xde, 0xad, 0xbe, 0xef]));
943+
assert_eq!(by_type.get(&65539).cloned(), Some(vec![0xca, 0xfe]));
944+
}
945+
904946
#[tokio::test]
905947
async fn test_cli_get_payment_details() {
906948
let bitcoind = TestBitcoind::new();

ldk-server-cli/src/main.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::path::PathBuf;
1212

1313
use clap::{CommandFactory, Parser, Subcommand};
1414
use clap_complete::{generate, Shell};
15-
use hex_conservative::DisplayHex;
15+
use hex_conservative::{DisplayHex, FromHex};
1616
use ldk_server_client::client::LdkServerClient;
1717
use ldk_server_client::config::{
1818
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::{
4545
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
4646
};
4747
use ldk_server_client::ldk_server_grpc::types::{
48-
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
49-
RouteParametersConfig,
48+
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, CustomTlvRecord,
49+
PageToken, RouteParametersConfig,
5050
};
5151
use ldk_server_client::{
5252
DEFAULT_EXPIRY_SECS, DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF, DEFAULT_MAX_PATH_COUNT,
@@ -314,6 +314,12 @@ enum Commands {
314314
help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)"
315315
)]
316316
max_channel_saturation_power_of_half: Option<u32>,
317+
#[arg(
318+
long = "custom-tlv",
319+
value_parser = parse_custom_tlv,
320+
help = "Custom TLV record to attach, format: <type_num>:<hex_value>. Repeatable. type_num must be >= 65536."
321+
)]
322+
custom_tlvs: Vec<(u64, Vec<u8>)>,
317323
},
318324
#[command(
319325
about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer"
@@ -810,6 +816,7 @@ async fn main() {
810816
max_total_cltv_expiry_delta,
811817
max_path_count,
812818
max_channel_saturation_power_of_half,
819+
custom_tlvs,
813820
} => {
814821
let amount_msat = amount.to_msat();
815822
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
@@ -822,12 +829,18 @@ async fn main() {
822829
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
823830
};
824831

832+
let proto_custom_tlvs: Vec<_> = custom_tlvs
833+
.into_iter()
834+
.map(|(type_num, value)| CustomTlvRecord { type_num, value: value.into() })
835+
.collect();
836+
825837
handle_response_result::<_, SpontaneousSendResponse>(
826838
client
827839
.spontaneous_send(SpontaneousSendRequest {
828840
amount_msat,
829841
node_id,
830842
route_parameters: Some(route_parameters),
843+
custom_tlvs: proto_custom_tlvs,
831844
})
832845
.await,
833846
);
@@ -1249,6 +1262,19 @@ fn parse_page_token(token_str: &str) -> Result<PageToken, LdkServerError> {
12491262
Ok(PageToken { token: parts[0].to_string(), index })
12501263
}
12511264

1265+
fn parse_custom_tlv(s: &str) -> Result<(u64, Vec<u8>), String> {
1266+
let (type_str, hex_str) =
1267+
s.split_once(':').ok_or_else(|| format!("expected <type_num>:<hex_value>, got '{s}'"))?;
1268+
let type_num: u64 =
1269+
type_str.parse().map_err(|e| format!("invalid type number '{type_str}': {e}"))?;
1270+
if type_num < 65536 {
1271+
return Err(format!("type number must be >= 65536, got {type_num}"));
1272+
}
1273+
let value =
1274+
Vec::<u8>::from_hex(hex_str).map_err(|e| format!("invalid hex value '{hex_str}': {e}"))?;
1275+
Ok((type_num, value))
1276+
}
1277+
12521278
fn handle_error_msg(msg: String) -> ! {
12531279
eprintln!("Error: {}", sanitize_for_terminal(msg));
12541280
std::process::exit(1);
@@ -1265,3 +1291,33 @@ fn handle_error(e: LdkServerError) -> ! {
12651291
eprintln!("Error ({}): {}", error_type, e.message);
12661292
std::process::exit(1); // Exit with status code 1 on error.
12671293
}
1294+
1295+
#[cfg(test)]
1296+
mod tests {
1297+
use super::*;
1298+
1299+
#[test]
1300+
fn parse_custom_tlv_accepts_valid_record() {
1301+
let (type_num, value) = parse_custom_tlv("65537:deadbeef").unwrap();
1302+
assert_eq!(type_num, 65537);
1303+
assert_eq!(value, vec![0xde, 0xad, 0xbe, 0xef]);
1304+
}
1305+
1306+
#[test]
1307+
fn parse_custom_tlv_rejects_missing_separator() {
1308+
let err = parse_custom_tlv("65537").unwrap_err();
1309+
assert!(err.contains("expected <type_num>:<hex_value>"));
1310+
}
1311+
1312+
#[test]
1313+
fn parse_custom_tlv_rejects_reserved_type() {
1314+
let err = parse_custom_tlv("65535:00").unwrap_err();
1315+
assert!(err.contains("type number must be >= 65536"));
1316+
}
1317+
1318+
#[test]
1319+
fn parse_custom_tlv_rejects_invalid_hex() {
1320+
let err = parse_custom_tlv("65537:not-hex").unwrap_err();
1321+
assert!(err.contains("invalid hex value"));
1322+
}
1323+
}

ldk-server-grpc/src/api.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,9 @@ pub struct SpontaneousSendRequest {
470470
/// Configuration options for payment routing and pathfinding.
471471
#[prost(message, optional, tag = "3")]
472472
pub route_parameters: ::core::option::Option<super::types::RouteParametersConfig>,
473+
/// Custom TLV records to attach to the outgoing payment.
474+
#[prost(message, repeated, tag = "4")]
475+
pub custom_tlvs: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
473476
}
474477
/// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned.
475478
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]

ldk-server-grpc/src/events.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ pub struct PaymentReceived {
150150
/// The payment details for the payment in event.
151151
#[prost(message, optional, tag = "1")]
152152
pub payment: ::core::option::Option<super::types::Payment>,
153+
/// Custom TLV records attached to the incoming payment, if any.
154+
#[prost(message, repeated, tag = "2")]
155+
pub custom_records: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
153156
}
154157
/// PaymentSuccessful indicates a sent payment was successful.
155158
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -184,6 +187,9 @@ pub struct PaymentClaimable {
184187
/// The payment details for the claimable payment.
185188
#[prost(message, optional, tag = "1")]
186189
pub payment: ::core::option::Option<super::types::Payment>,
190+
/// Custom TLV records attached to the claimable payment, if any.
191+
#[prost(message, repeated, tag = "2")]
192+
pub custom_records: ::prost::alloc::vec::Vec<super::types::CustomTlvRecord>,
187193
}
188194
/// PaymentForwarded indicates a payment was forwarded through the node.
189195
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,9 @@ message SpontaneousSendRequest {
368368

369369
// Configuration options for payment routing and pathfinding.
370370
optional types.RouteParametersConfig route_parameters = 3;
371+
372+
// Custom TLV records to attach to the outgoing payment.
373+
repeated types.CustomTlvRecord custom_tlvs = 4;
371374
}
372375

373376
// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned.

ldk-server-grpc/src/proto/events.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ message ChannelStateChanged {
9696
message PaymentReceived {
9797
// The payment details for the payment in event.
9898
types.Payment payment = 1;
99+
// Custom TLV records attached to the incoming payment, if any.
100+
repeated types.CustomTlvRecord custom_records = 2;
99101
}
100102

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

120124
// PaymentForwarded indicates a payment was forwarded through the node.

ldk-server-grpc/src/proto/types.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,3 +945,11 @@ message Bolt11Feature {
945945
// Whether this feature is known.
946946
bool is_known = 3;
947947
}
948+
949+
// Custom TLV record attached to a payment.
950+
message CustomTlvRecord {
951+
// TLV type number.
952+
uint64 type_num = 1;
953+
// Raw TLV value.
954+
bytes value = 2;
955+
}

ldk-server-grpc/src/types.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,20 @@ pub struct Bolt11Feature {
12441244
#[prost(bool, tag = "3")]
12451245
pub is_known: bool,
12461246
}
1247+
/// Custom TLV record attached to a payment.
1248+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1249+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1250+
#[cfg_attr(feature = "serde", serde(default))]
1251+
#[allow(clippy::derive_partial_eq_without_eq)]
1252+
#[derive(Clone, PartialEq, ::prost::Message)]
1253+
pub struct CustomTlvRecord {
1254+
/// TLV type number.
1255+
#[prost(uint64, tag = "1")]
1256+
pub type_num: u64,
1257+
/// Raw TLV value.
1258+
#[prost(bytes = "bytes", tag = "2")]
1259+
pub value: ::prost::bytes::Bytes,
1260+
}
12471261
/// Represents the direction of a payment.
12481262
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12491263
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]

ldk-server/src/api/mod.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ use std::collections::HashMap;
1111

1212
use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure};
1313
use ldk_node::lightning::routing::router::RouteParametersConfig;
14+
use ldk_node::CustomTlvRecord as NodeCustomTlvRecord;
1415
use ldk_server_grpc::types::channel_config::MaxDustHtlcExposure;
15-
use ldk_server_grpc::types::Bolt11Feature;
16+
use ldk_server_grpc::types::{Bolt11Feature, CustomTlvRecord as ProtoCustomTlvRecord};
1617

1718
use crate::api::error::LdkServerError;
1819
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
@@ -129,6 +130,14 @@ pub(crate) fn build_route_parameters_config_from_proto(
129130
}
130131
}
131132

133+
pub(crate) fn proto_to_node_custom_tlv(proto: &ProtoCustomTlvRecord) -> NodeCustomTlvRecord {
134+
NodeCustomTlvRecord { type_num: proto.type_num, value: proto.value.to_vec() }
135+
}
136+
137+
pub(crate) fn node_to_proto_custom_tlv(node: &NodeCustomTlvRecord) -> ProtoCustomTlvRecord {
138+
ProtoCustomTlvRecord { type_num: node.type_num, value: node.value.clone().into() }
139+
}
140+
132141
/// Decodes feature flags into a map keyed by bit number. Feature names are derived
133142
/// from LDK's `Features::Display` impl, so they stay in sync automatically.
134143
///
@@ -182,3 +191,43 @@ fn parse_feature_name(display: &str) -> (&str, bool) {
182191
}
183192
("unknown", false)
184193
}
194+
195+
#[cfg(test)]
196+
mod tests {
197+
use super::*;
198+
199+
#[test]
200+
fn proto_to_node_custom_tlv_preserves_fields() {
201+
let proto =
202+
ProtoCustomTlvRecord { type_num: 65537, value: vec![0xde, 0xad, 0xbe, 0xef].into() };
203+
let node = proto_to_node_custom_tlv(&proto);
204+
assert_eq!(node.type_num, 65537);
205+
assert_eq!(node.value, vec![0xde, 0xad, 0xbe, 0xef]);
206+
}
207+
208+
#[test]
209+
fn node_to_proto_custom_tlv_preserves_fields() {
210+
let node = NodeCustomTlvRecord { type_num: 65537, value: vec![0xde, 0xad, 0xbe, 0xef] };
211+
let proto = node_to_proto_custom_tlv(&node);
212+
assert_eq!(proto.type_num, 65537);
213+
assert_eq!(proto.value.to_vec(), vec![0xde, 0xad, 0xbe, 0xef]);
214+
}
215+
216+
#[test]
217+
fn empty_custom_tlv_value_round_trips() {
218+
let proto = ProtoCustomTlvRecord { type_num: 70000, value: Vec::new().into() };
219+
let node = proto_to_node_custom_tlv(&proto);
220+
let back = node_to_proto_custom_tlv(&node);
221+
assert_eq!(back.type_num, 70000);
222+
assert!(back.value.is_empty());
223+
}
224+
225+
#[test]
226+
fn non_empty_custom_tlv_value_round_trips() {
227+
let proto = ProtoCustomTlvRecord { type_num: 70001, value: vec![1, 2, 3, 4].into() };
228+
let node = proto_to_node_custom_tlv(&proto);
229+
let back = node_to_proto_custom_tlv(&node);
230+
assert_eq!(back.type_num, 70001);
231+
assert_eq!(back.value.to_vec(), vec![1, 2, 3, 4]);
232+
}
233+
}

ldk-server/src/api/spontaneous_send.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ use std::sync::Arc;
1313
use ldk_node::bitcoin::secp256k1::PublicKey;
1414
use ldk_server_grpc::api::{SpontaneousSendRequest, SpontaneousSendResponse};
1515

16-
use crate::api::build_route_parameters_config_from_proto;
1716
use crate::api::error::LdkServerError;
1817
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
18+
use crate::api::{build_route_parameters_config_from_proto, proto_to_node_custom_tlv};
1919
use crate::service::Context;
2020

2121
pub(crate) async fn handle_spontaneous_send_request(
@@ -27,8 +27,18 @@ pub(crate) async fn handle_spontaneous_send_request(
2727

2828
let route_parameters = build_route_parameters_config_from_proto(request.route_parameters)?;
2929

30-
let payment_id =
31-
context.node.spontaneous_payment().send(request.amount_msat, node_id, route_parameters)?;
30+
let payment_id = if request.custom_tlvs.is_empty() {
31+
context.node.spontaneous_payment().send(request.amount_msat, node_id, route_parameters)?
32+
} else {
33+
let custom_tlvs: Vec<_> =
34+
request.custom_tlvs.iter().map(proto_to_node_custom_tlv).collect();
35+
context.node.spontaneous_payment().send_with_custom_tlvs(
36+
request.amount_msat,
37+
node_id,
38+
route_parameters,
39+
custom_tlvs,
40+
)?
41+
};
3242

3343
let response = SpontaneousSendResponse { payment_id: payment_id.to_string() };
3444
Ok(response)

0 commit comments

Comments
 (0)