Support retired TLV fields in object-constructing macros
What changed, and why it matters
This commit is a feature enhancement for LDK's internal serialization macros. It allows developers to mark old protocol fields as 'retired' (reserved but no longer used) in more places, so those type numbers cannot be accidentally reused. The change itself is not a security bug fix; it is infrastructure that makes it easier to safely reserve obsolete TLV type numbers. There is no direct exploit here, but it helps prevent future protocol-versioning mistakes.
Treat as a normal feature/maintenance commit. Reviewers should confirm that the new retired-field handling does not accidentally drop fields that should still be parsed, and that the macro filters correctly distinguish 'retired' from active fields. No urgent security action is required.
Security signals we found
TLV type-number reservation mechanism extended to more macro-generated code paths
Prevents accidental reuse of retired protocol field type numbers
Avoids UnknownRequiredFeature decode failures for obsolete even-type fields
No memory-safety, cryptographic, or authentication changes present
No CVE, advisory, or vendor security disclosure referenced in commit
Evidence from the diff
The patch extends existing macro filters in lightning-macros and ser_macros.rs to treat ‘retired’ TLV field annotations like ‘legacy’ and ‘custom’ fields when generating struct literals, enum match patterns, and message deserialization code. Previously, ‘retired’ fields caused a compile-time error in object-constructing macros because they have no backing struct field. The commit removes that compile_error! arm, routes impl_writeable_msg! through drop_legacy_field_definition!, and updates process_fields/skip_legacy_fields/drop_legacy_field_definition to skip retired fields. Tests verify that retired even-type TLV records are ignored on read and do not trigger UnknownRequiredFeature, while serialization omits them.
Changed components
lightning-macros/src/lib.rslightning/src/util/ser_macros.rsimpl_ser_tlv_based! macroimpl_ser_tlv_based_enum! macroimpl_writeable_msg! macroTLV serialization/deserialization frameworkInspect captured patch +120 / −22
### lightning-macros/src/lib.rs
@@ -125,7 +125,8 @@ fn process_fields(group: Group) -> proc_macro::TokenStream {
// Fields should take the form `ref field_name: ty_info` where `ty_info`
// may be a single ident or may be a group. We skip the field if `ty_info`
- // is a group where the first token is the ident `legacy`.
+ // is a group where the first token is the ident `legacy` or `custom`, or
+ // if it is the bare ident `retired`.
let ref_ident = fields_stream.next().unwrap();
expect_ident(&ref_ident, Some("ref"));
let field_name_ident = fields_stream.next().unwrap();
@@ -135,13 +136,17 @@ fn process_fields(group: Group) -> proc_macro::TokenStream {
let com = fields_stream.next().unwrap();
expect_punct(&com, ',');
- if let TokenTree::Group(group) = ty_info {
+ if let TokenTree::Group(group) = &ty_info {
let first_group_tok = group.stream().into_iter().next().unwrap();
if let TokenTree::Ident(ident) = first_group_tok {
if ident.to_string() == "legacy" || ident.to_string() == "custom" {
continue;
}
}
+ } else if let TokenTree::Ident(ident) = &ty_info {
+ if ident.to_string() == "retired" {
+ continue;
+ }
}
let field = [ref_ident, field_name_ident, com];
@@ -155,22 +160,24 @@ fn process_fields(group: Group) -> proc_macro::TokenStream {
computed_fields
}
-/// Scans a match statement for legacy or custom fields which should be skipped.
+/// Scans a match statement for legacy, custom, or retired fields which should be skipped.
///
/// This is used internally in LDK's TLV serialization logic and is not expected to be used by
/// other crates.
///
/// Wraps a `match self {..}` statement and scans the fields in the match patterns (in the form
-/// `ref $field_name: $field_ty`) for types marked `legacy` or `custom`, skipping those fields.
+/// `ref $field_name: $field_ty`) for types marked `legacy`, `custom`, or `retired`, skipping
+/// those fields.
///
-/// Specifically, it expects input like the following, simply dropping `field3` and the
-/// `: $field_ty` after each field name.
+/// Specifically, it expects input like the following, simply dropping `field3` and `field4` and
+/// the `: $field_ty` after each field name.
/// ```ignore
/// match self {
/// Enum::Variant {
/// ref field1: option,
/// ref field2: (option, explicit_type: u64),
/// ref field3: (legacy, u64, {}, {}), // will be skipped
+/// ref field4: retired, // will be skipped
/// ..
/// } => expression
/// }
@@ -240,7 +247,8 @@ pub fn skip_legacy_fields(expr: TokenStream) -> TokenStream {
res
}
-/// Scans an enum definition for fields initialized with `legacy` types and drops them.
+/// Scans an enum definition for fields initialized with `legacy` or `retired` types and drops
+/// them.
///
/// This is used internally in LDK's TLV serialization logic and is not expected to be used by
/// other crates.
@@ -250,9 +258,11 @@ pub fn skip_legacy_fields(expr: TokenStream) -> TokenStream {
/// drop_legacy_field_definition!(Self {
/// field1: $crate::_init_tlv_based_struct_field!(field1, option),
/// field2: $crate::_init_tlv_based_struct_field!(field2, (legacy, u64, {})),
+/// field3: $crate::_init_tlv_based_struct_field!(field3, retired),
/// })
/// ```
-/// and will drop fields defined like `field2` with a type starting with `legacy`.
+/// and will drop fields defined like `field2` with a type starting with `legacy` or like
+/// `field3` with the type `retired`.
#[proc_macro]
pub fn drop_legacy_field_definition(expr: TokenStream) -> TokenStream {
let mut st = if let Ok(parsed) = parse::<syn::Expr>(expr) {
@@ -280,15 +290,19 @@ pub fn drop_legacy_field_definition(expr: TokenStream) -> TokenStream {
if let syn::Expr::Macro(syn::ExprMacro { mac, .. }) = &field.expr {
let macro_name = mac.path.segments.last().unwrap().ident.to_string();
let is_init = macro_name == "_init_tlv_based_struct_field";
- // Skip `field_name` and `:`, giving us just the type's group
+ // Skip `field_name` and the comma, giving us just the type
let ty_tokens = mac.tokens.clone().into_iter().skip(2).next();
- if let Some(proc_macro2::TokenTree::Group(group)) = ty_tokens {
+ if let Some(proc_macro2::TokenTree::Group(group)) = &ty_tokens {
let first_token = group.stream().into_iter().next();
if let Some(proc_macro2::TokenTree::Ident(ident)) = first_token {
if is_init && ident == "legacy" {
continue;
}
}
+ } else if let Some(proc_macro2::TokenTree::Ident(ident)) = &ty_tokens {
+ if is_init && ident == "retired" {
+ continue;
+ }
}
}
st.fields.push(field);
### lightning/src/util/ser_macros.rs
@@ -740,7 +740,7 @@ macro_rules! _decode_tlv_stream_range {
///
/// This is useful to implement a [`CustomMessageReader`].
///
-/// Currently `$fieldty` may only be `option`, i.e., `$tlvfield` is optional field.
+/// Currently `$fieldty` may only be `option` (making `$tlvfield` an optional field) or `retired`.
///
/// For example,
/// ```
@@ -781,10 +781,10 @@ macro_rules! impl_writeable_msg {
$(let $field = $crate::util::ser::Readable::read(r)?;)*
$($crate::_init_tlv_field_var!($tlvfield, $fieldty);)*
$crate::decode_tlv_stream!(r, {$(($type, $tlvfield, $fieldty)),*});
- Ok(Self {
+ Ok(::lightning_macros::drop_legacy_field_definition!(Self {
$($field,)*
$($tlvfield: $crate::_init_tlv_based_struct_field!($tlvfield, $fieldty)),*
- })
+ }))
}
}
}
@@ -949,9 +949,6 @@ macro_rules! _init_tlv_based_struct_field {
($field: ident, (static_value, $value: expr)) => {
$field
};
- ($field: ident, retired) => {
- compile_error!("`retired` fields have no backing struct field and cannot be used in macros that construct the deserialized object; use them with `write_tlv_fields!`/`read_tlv_fields!`-style manual implementations")
- };
($field: ident, option) => {
$field
};
@@ -964,7 +961,7 @@ macro_rules! _init_tlv_based_struct_field {
($field: ident, (option: $trait: ident $(, $read_arg: expr)?)) => {
$crate::_init_tlv_based_struct_field!($field, option)
};
- // Note that legacy TLVs are eaten by `drop_legacy_field_definition`
+ // Note that legacy and retired TLVs are eaten by `drop_legacy_field_definition`
($field: ident, upgradable_required) => {
$field.0.unwrap()
};
@@ -1120,9 +1117,7 @@ macro_rules! _decode_and_build {
/// type number but which is no longer written. Nothing is serialized, and on read any value
/// present is ignored (its bytes are consumed, so retired even types written by a previous
/// version do not fail the read). The entry reserves the type number against reuse. If the
-/// field is still written for prior versions' benefit, use `legacy` instead. Note that
-/// `retired` is not supported by macros which construct the deserialized object (including
-/// this one); use it in [`write_tlv_fields`]/[`read_tlv_fields`]-style manual implementations.
+/// field is still written for prior versions' benefit, use `legacy` instead.
/// If `$fieldty` is `(legacy, $ty, $read, $write)` then, when writing, the function $write will be
/// called with the object being serialized and a returned `Option` and is written as a TLV if
/// `Some`. When reading, an optional field of type `$ty` is read, and after all TLV fields are
@@ -1161,8 +1156,6 @@ macro_rules! _decode_and_build {
/// [`MaybeReadable`]: crate::util::ser::MaybeReadable
/// [`Writeable`]: crate::util::ser::Writeable
/// [`Vec`]: crate::prelude::Vec
-/// [`write_tlv_fields`]: crate::write_tlv_fields
-/// [`read_tlv_fields`]: crate::read_tlv_fields
#[macro_export]
macro_rules! impl_ser_tlv_based {
($st: ident, {$(($type: expr, $field: ident, $fieldty: tt)),* $(,)*}) => {
@@ -2059,6 +2052,97 @@ mod tests {
assert_eq!(encoded, <Vec<u8>>::from_hex("0a0108000000000000002a").unwrap());
}
+ #[derive(Debug, PartialEq)]
+ struct WithRetired {
+ a: u64,
+ b: Option<u32>,
+ }
+ impl_ser_tlv_based!(WithRetired, {
+ (1, a, required),
+ (2, _old_even, retired),
+ (3, b, option),
+ });
+
+ // Same fields and type numbers, without the retired entry.
+ #[derive(Debug, PartialEq)]
+ struct WithoutRetired {
+ a: u64,
+ b: Option<u32>,
+ }
+ impl_ser_tlv_based!(WithoutRetired, {
+ (1, a, required),
+ (3, b, option),
+ });
+
+ #[test]
+ fn retired_tlvs_in_struct_macro() {
+ let with_retired = WithRetired { a: 0xdead, b: Some(0x1dea) };
+ let without_retired = WithoutRetired { a: 0xdead, b: Some(0x1dea) };
+ let encoded = with_retired.encode();
+ // The retired entry writes nothing.
+ assert_eq!(encoded, without_retired.encode());
+ assert_eq!(with_retired.serialized_length(), encoded.len());
+
+ // A record at the retired (even) type number is skipped on read...
+ let stream = <Vec<u8>>::from_hex(concat!(
+ "13", // stream length
+ "0108000000000000dead", // (1, a)
+ "0201ff", // (2, _old_even), ignored
+ "030400001dea", // (3, b)
+ ))
+ .unwrap();
+ let read: WithRetired = Readable::read(&mut &stream[..]).unwrap();
+ assert_eq!(read, with_retired);
+ // ...but without the retired entry in the field list, the even type fails the read.
+ let err = <WithoutRetired as Readable>::read(&mut &stream[..]).unwrap_err();
+ assert!(matches!(err, DecodeError::UnknownRequiredFeature));
+ }
+
+ #[derive(Debug, PartialEq)]
+ enum RetiredEnum {
+ A { x: u64 },
+ }
+ impl_ser_tlv_based_enum!(RetiredEnum,
+ (0, A) => {(1, x, required), (2, _old, retired)},
+ );
+
+ #[test]
+ fn retired_tlvs_in_enum_macro() {
+ let e = RetiredEnum::A { x: 0x2a };
+ let encoded = e.encode();
+ // Variant byte, stream length, then only the type-1 record.
+ assert_eq!(encoded, <Vec<u8>>::from_hex("000a0108000000000000002a").unwrap());
+
+ // A record at the retired type number is skipped on read.
+ let with_retired = <Vec<u8>>::from_hex("000d0108000000000000002a0201ff").unwrap();
+ let read: RetiredEnum = Readable::read(&mut &with_retired[..]).unwrap();
+ assert_eq!(read, e);
+ }
+
+ #[derive(Debug, PartialEq)]
+ struct RetiredMsg {
+ fixed: u32,
+ opt: Option<u32>,
+ }
+ impl_writeable_msg!(RetiredMsg, { fixed }, {
+ (0, _old_even, retired),
+ (1, opt, option),
+ });
+
+ #[test]
+ fn retired_tlvs_in_msg_macro() {
+ let msg = RetiredMsg { fixed: 0x2a, opt: Some(7) };
+ let encoded = msg.encode();
+ // The non-TLV field, then an unprefixed TLV stream with only the type-1 record.
+ assert_eq!(encoded, <Vec<u8>>::from_hex("0000002a010400000007").unwrap());
+
+ // A record at the retired (even) type number is skipped on read.
+ let with_retired = <Vec<u8>>::from_hex("0000002a0001ff010400000007").unwrap();
+ let read: RetiredMsg =
+ LengthReadable::read_from_fixed_length_buffer(&mut &with_retired[..]).unwrap();
+ assert_eq!(read, msg);
+ }
+
#[derive(Debug, Eq, PartialEq)]
struct EmptyMsg {}
impl_writeable_msg!(EmptyMsg, {}, {});Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.