From 36a9020c1e94a76bf6d06b53b278ca28c47945fa Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:34:04 -0700 Subject: [PATCH 01/10] lnwire: bump pure-TLV signed range to 0..=239; sig TLVs to type 240 The taproot-gossip BOLT extension was updated to align with BOLT 12, which long since moved its signature TLVs to type 240 and widened the signed TLV range from 0..=159 to 0..=239. Mirror that here: * pureTLVUnsignedRangeOneStart: 160 -> 240 * channel_announcement_2.Signature: TlvType160 -> TlvType240 * channel_update_2.Signature: TlvType160 -> TlvType240 * node_announcement_2.Signature: TlvType160 -> TlvType240 Update the pure-TLV test fixtures and the test message types correspondingly. --- lnwire/channel_announcement_2.go | 2 +- lnwire/channel_announcement_2_test.go | 2 +- lnwire/channel_update_2.go | 2 +- lnwire/channel_update_2_test.go | 2 +- lnwire/node_announcement_2.go | 2 +- lnwire/node_announcement_2_test.go | 2 +- lnwire/pure_tlv.go | 6 +++--- lnwire/pure_tlv_test.go | 12 ++++++------ 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lnwire/channel_announcement_2.go b/lnwire/channel_announcement_2.go index 9227584b6f5..e87b36c7acd 100644 --- a/lnwire/channel_announcement_2.go +++ b/lnwire/channel_announcement_2.go @@ -61,7 +61,7 @@ type ChannelAnnouncement2 struct { // Signature is a Schnorr signature over serialised signed-range TLV // stream of the message. - Signature tlv.RecordT[tlv.TlvType160, Sig] + Signature tlv.RecordT[tlv.TlvType240, Sig] // Any extra fields in the signed range that we do not yet know about, // but we need to keep them for signature validation and to produce a diff --git a/lnwire/channel_announcement_2_test.go b/lnwire/channel_announcement_2_test.go index 3a2ce28a903..14d0ed64b1c 100644 --- a/lnwire/channel_announcement_2_test.go +++ b/lnwire/channel_announcement_2_test.go @@ -75,7 +75,7 @@ func TestChanAnn2EncodeDecode(t *testing.T) { 0x79, 0x79, // value. // Signature. - 0xa0, // type. + 0xf0, // type. 0x40, // length. 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, diff --git a/lnwire/channel_update_2.go b/lnwire/channel_update_2.go index 3fba2470df3..13d3ed74261 100644 --- a/lnwire/channel_update_2.go +++ b/lnwire/channel_update_2.go @@ -77,7 +77,7 @@ type ChannelUpdate2 struct { // Signature is used to validate the announced data and prove the // ownership of node id. - Signature tlv.RecordT[tlv.TlvType160, Sig] + Signature tlv.RecordT[tlv.TlvType240, Sig] // Any extra fields in the signed range that we do not yet know about, // but we need to keep them for signature validation and to produce a diff --git a/lnwire/channel_update_2_test.go b/lnwire/channel_update_2_test.go index 4e771d89f00..1f53ec5f328 100644 --- a/lnwire/channel_update_2_test.go +++ b/lnwire/channel_update_2_test.go @@ -79,7 +79,7 @@ func TestChanUpdate2EncodeDecode(t *testing.T) { 0x79, 0x79, // value. // Signature. - 0xa0, // type. + 0xf0, // type. 0x40, // length. 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, diff --git a/lnwire/node_announcement_2.go b/lnwire/node_announcement_2.go index 93a3792a2d8..d7676bc1fbb 100644 --- a/lnwire/node_announcement_2.go +++ b/lnwire/node_announcement_2.go @@ -59,7 +59,7 @@ type NodeAnnouncement2 struct { // Signature is used to validate the announced data and prove the // ownership of node id. - Signature tlv.RecordT[tlv.TlvType160, Sig] + Signature tlv.RecordT[tlv.TlvType240, Sig] // Any extra fields in the signed range that we do not yet know about, // but we need to keep them for signature validation and to produce a diff --git a/lnwire/node_announcement_2_test.go b/lnwire/node_announcement_2_test.go index 0aea6614789..b03677ab15b 100644 --- a/lnwire/node_announcement_2_test.go +++ b/lnwire/node_announcement_2_test.go @@ -80,7 +80,7 @@ func TestNodeAnn2EncodeDecode(t *testing.T) { 0x23, 0x28, // port 9000. // Signature. - 0xa0, // type. + 0xf0, // type. 0x40, // length. 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, diff --git a/lnwire/pure_tlv.go b/lnwire/pure_tlv.go index 8e6f7bd9fc3..260dc6f62c2 100644 --- a/lnwire/pure_tlv.go +++ b/lnwire/pure_tlv.go @@ -10,11 +10,11 @@ const ( // pureTLVUnsignedRangeOneStart defines the start of the first unsigned // TLV range used for pure TLV messages. The range is inclusive of this // number. - pureTLVUnsignedRangeOneStart = 160 + pureTLVUnsignedRangeOneStart = 240 // pureTLVSignedSecondRangeStart defines the start of the second signed // TLV range used for pure TLV messages. The range is inclusive of this - // number. Note that the first range is the inclusive range of 0-159. + // number. Note that the first range is the inclusive range of 0-239. pureTLVSignedSecondRangeStart = 1000000000 // pureTLVUnsignedRangeTwoStart defines the start of the second unsigned @@ -24,7 +24,7 @@ const ( // PureTLVMessage describes an LN message that is a pure TLV stream. If the // message includes a signature, it will sign all the TLV records in the -// inclusive ranges: 0 to 159 and 1000000000 to 2999999999. +// inclusive ranges: 0 to 239 and 1000000000 to 2999999999. type PureTLVMessage interface { // AllRecords returns all the TLV records for the message. This will // include all the records we know about along with any that we don't diff --git a/lnwire/pure_tlv_test.go b/lnwire/pure_tlv_test.go index a81a89ecb6d..31fb167b18a 100644 --- a/lnwire/pure_tlv_test.go +++ b/lnwire/pure_tlv_test.go @@ -113,7 +113,7 @@ type MsgV1 struct { Capacity tlv.OptionalRecordT[tlv.TlvType1, MilliSatoshi] // Signature in the unsigned range. - Signature tlv.RecordT[tlv.TlvType160, Sig] + Signature tlv.RecordT[tlv.TlvType240, Sig] // Any extra fields in the signed range that we do not yet know about, // but we need to keep them for signature validation and to produce a @@ -130,7 +130,7 @@ func newMsgV1(nodeKey *btcec.PublicKey, capacity *MilliSatoshi) *MsgV1 { NodeKey: tlv.NewPrimitiveRecord[tlv.TlvType0]( nodeKey, ), - Signature: tlv.NewRecordT[tlv.TlvType160]( + Signature: tlv.NewRecordT[tlv.TlvType240]( testSchnorrSig, ), ExtraSignedFields: make(ExtraSignedFields), @@ -230,11 +230,11 @@ type MsgV2 struct { SecondPeer tlv.OptionalRecordT[tlv.TlvType5, TrueBoolean] // Signature in the unsigned range. - Signature tlv.RecordT[tlv.TlvType160, Sig] + Signature tlv.RecordT[tlv.TlvType240, Sig] // Another field in the unsigned range. An older node can throw this // away. - SPVProof tlv.RecordT[tlv.TlvType161, []byte] + SPVProof tlv.RecordT[tlv.TlvType241, []byte] // A new field in the second signed range. An older node should keep // this since it is part of the serialised message that is signed. @@ -258,10 +258,10 @@ func newMsgV2(nodeKey *btcec.PublicKey, capacity *MilliSatoshi, newMsg := &MsgV2{ NodeKey: tlv.NewPrimitiveRecord[tlv.TlvType0](nodeKey), - SPVProof: tlv.NewPrimitiveRecord[tlv.TlvType161](spvProof), + SPVProof: tlv.NewPrimitiveRecord[tlv.TlvType241](spvProof), Num: tlv.NewPrimitiveRecord[tlv.TlvType1000000000](num), Other: tlv.NewPrimitiveRecord[tlv.TlvType3000000000](num), - Signature: tlv.NewRecordT[tlv.TlvType160]( + Signature: tlv.NewRecordT[tlv.TlvType240]( testSchnorrSig, ), ExtraSignedFields: make(ExtraSignedFields), From 560e6d2c4f0d65bee61727973e7670ada9c1c4dd Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:35:59 -0700 Subject: [PATCH 02/10] lnwire: enforce compulsory-field presence on gossip v2 reader The BOLT taproot-gossip spec now requires each gossip v2 reader to reject messages that are missing any of their compulsory fields with a warning/close/ignore action. lnwire's three gossip v2 Decode implementations silently zero-valued the missing TLVs, which made later validation harder. Add a small AssertRequiredPresent helper to pure_tlv.go and call it from each Decode after DecodeWithParsedTypesP2P / ExtractRecords: * channel_announcement_2: short_channel_id, outpoint, capacity, node_id_1, node_id_2, signature. * channel_update_2: short_channel_id, block_height, signature. * node_announcement_2: features, block_height, node_id, signature. --- lnwire/channel_announcement_2.go | 12 ++++++++++++ lnwire/channel_update_2.go | 9 +++++++++ lnwire/node_announcement_2.go | 10 ++++++++++ lnwire/pure_tlv.go | 16 ++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/lnwire/channel_announcement_2.go b/lnwire/channel_announcement_2.go index e87b36c7acd..ae509e602da 100644 --- a/lnwire/channel_announcement_2.go +++ b/lnwire/channel_announcement_2.go @@ -166,6 +166,18 @@ func (c *ChannelAnnouncement2) Decode(r io.Reader, _ uint32) error { return err } + if err := AssertRequiredPresent( + typeMap, + c.ShortChannelID.TlvType(), + c.Outpoint.TlvType(), + c.Capacity.TlvType(), + c.NodeID1.TlvType(), + c.NodeID2.TlvType(), + c.Signature.TlvType(), + ); err != nil { + return err + } + // By default, the chain-hash is the bitcoin mainnet genesis block hash. c.ChainHash.Val = *chaincfg.MainNetParams.GenesisHash if _, ok := typeMap[c.ChainHash.TlvType()]; ok { diff --git a/lnwire/channel_update_2.go b/lnwire/channel_update_2.go index 13d3ed74261..8421c81da7b 100644 --- a/lnwire/channel_update_2.go +++ b/lnwire/channel_update_2.go @@ -128,6 +128,15 @@ func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error { } c.Signature.Val.ForceSchnorr() + if err := AssertRequiredPresent( + typeMap, + c.ShortChannelID.TlvType(), + c.BlockHeight.TlvType(), + c.Signature.TlvType(), + ); err != nil { + return err + } + // By default, the chain-hash is the bitcoin mainnet genesis block hash. c.ChainHash.Val = *chaincfg.MainNetParams.GenesisHash if _, ok := typeMap[c.ChainHash.TlvType()]; ok { diff --git a/lnwire/node_announcement_2.go b/lnwire/node_announcement_2.go index d7676bc1fbb..5d1644444da 100644 --- a/lnwire/node_announcement_2.go +++ b/lnwire/node_announcement_2.go @@ -154,6 +154,16 @@ func (n *NodeAnnouncement2) Decode(r io.Reader, _ uint32) error { return err } + if err := AssertRequiredPresent( + typeMap, + n.Features.TlvType(), + n.BlockHeight.TlvType(), + n.NodeID.TlvType(), + n.Signature.TlvType(), + ); err != nil { + return err + } + if _, ok := typeMap[n.Alias.TlvType()]; ok { n.Alias = tlv.SomeRecordT(alias) } diff --git a/lnwire/pure_tlv.go b/lnwire/pure_tlv.go index 260dc6f62c2..b24164b6908 100644 --- a/lnwire/pure_tlv.go +++ b/lnwire/pure_tlv.go @@ -2,6 +2,7 @@ package lnwire import ( "bytes" + "fmt" "github.com/lightningnetwork/lnd/tlv" ) @@ -66,6 +67,21 @@ func InUnsignedRange(t tlv.Type) bool { t >= pureTLVUnsignedRangeTwoStart } +// AssertRequiredPresent returns an error if any of the given TLV types is +// missing from the parsed type map (as returned by the various +// DecodeWithParsedTypes helpers). It is used to enforce the spec's +// reader-side requirement that compulsory TLVs are present in a received +// pure-TLV message. +func AssertRequiredPresent(typeMap tlv.TypeMap, required ...tlv.Type) error { + for _, t := range required { + if _, ok := typeMap[t]; !ok { + return fmt.Errorf("required TLV type %d missing", t) + } + } + + return nil +} + // ExtraSignedFields is a type that stores a map from TLV types in the signed // range (for PureMessages) to their corresponding serialised values. This type // can be used to keep around data that we don't yet understand but that we need From 1a5531eef02afabc53ab6e6eb23a282a67daa9d9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:36:41 -0700 Subject: [PATCH 03/10] lnwire: reject tor_v3_address with port 0 in node_announcement_2 The BOLT taproot-gossip spec was tightened so that the port-not-zero rule applies to every advertised address type, including tor_v3_address. Mirror that in the lnwire encoder/decoder for tor v3 addresses in node_announcement_2: fail encoding if the sender tries to emit a tor v3 address with port 0, and fail decoding if a received message contains one. --- lnwire/node_announcement_2.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lnwire/node_announcement_2.go b/lnwire/node_announcement_2.go index 5d1644444da..2b178a881bc 100644 --- a/lnwire/node_announcement_2.go +++ b/lnwire/node_announcement_2.go @@ -546,6 +546,11 @@ func (a *TorV3Addrs) Record() tlv.Record { func torV3AddrsEncoder(w io.Writer, val interface{}, _ *[8]byte) error { if v, ok := val.(*TorV3Addrs); ok { for _, addr := range *v { + if addr.Port == 0 { + return fmt.Errorf("tor_v3_address port " + + "must not be 0") + } + encodedHostLen := tor.V3Len - tor.OnionSuffixLen host, err := tor.Base32Encoding.DecodeString( addr.OnionService[:encodedHostLen], @@ -610,6 +615,10 @@ func torV3AddrsDecoder(r io.Reader, val interface{}, _ *[8]byte, } port := int(binary.BigEndian.Uint16(p[:])) + if port == 0 { + return fmt.Errorf("tor_v3_address port " + + "must not be 0") + } addrs = append(addrs, &tor.OnionAddr{ OnionService: onionService, Port: port, From 4546515104912fa9da3c459de4a6523a3cf0696a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:41:06 -0700 Subject: [PATCH 04/10] lnwire: collapse gossip_timestamp_range's two block-height TLVs into one The taproot-gossip BOLT extension swapped the two-TLV (first_block @ type 2, block_height_range @ type 4) layout in gossip_timestamp_range for a single TLV at type 2 holding both fields: u32 first_block_height and tu32 num_blocks. The two fields are always set together, and the truncated u32 num_blocks saves a few bytes on the wire. Replace the FirstBlockHeight (type 2, u32) and BlockRange (type 4, u32) optional records on GossipTimestampRange with a single BlockHeightRange (type 2) optional record holding a new BlockHeightRange struct. The struct's Record() method uses MakeDynamicRecord with EUint32T+ETUint32T for the encoder and DUint32+DTUint32 for the decoder. The property-test factory in test_message.go is updated to draw a single optional BlockHeightRange instead of two independent fields. --- lnwire/gossip_timestamp_range.go | 102 ++++++++++++++++++++++--------- lnwire/test_message.go | 24 ++++---- 2 files changed, 84 insertions(+), 42 deletions(-) diff --git a/lnwire/gossip_timestamp_range.go b/lnwire/gossip_timestamp_range.go index 45ff1f939af..c169b35f74b 100644 --- a/lnwire/gossip_timestamp_range.go +++ b/lnwire/gossip_timestamp_range.go @@ -32,16 +32,12 @@ type GossipTimestampRange struct { // Unix timestamps. TimestampRange uint32 - // FirstBlockHeight is the height of earliest announcement message that - // should be sent by the receiver. This is used only for querying - // announcement messages that use block heights as a timestamp. - FirstBlockHeight tlv.OptionalRecordT[tlv.TlvType2, uint32] - - // BlockRange is the horizon beyond FirstBlockHeight that any - // announcement messages should be sent for. The receiving node MUST NOT - // send any announcements that have a timestamp greater than - // FirstBlockHeight + BlockRange. - BlockRange tlv.OptionalRecordT[tlv.TlvType4, uint32] + // BlockHeightRange is the block-height equivalent of (FirstTimestamp, + // TimestampRange) used for gossip messages timestamped by block + // height. The receiving node MUST NOT send any announcements that have + // a block height greater than BlockHeightRange.FirstBlockHeight + + // BlockHeightRange.NumBlocks. + BlockHeightRange tlv.OptionalRecordT[tlv.TlvType2, BlockHeightRange] // ExtraData is the set of data that was appended to this message to // fill out the full maximum transport message size. These fields can @@ -54,6 +50,65 @@ func NewGossipTimestampRange() *GossipTimestampRange { return &GossipTimestampRange{} } +// BlockHeightRange describes a window of blocks that an announcement may have +// been timestamped within. It is serialised as a u32 first_block_height +// followed by a tu32 num_blocks; the tu32 (truncated uint32) drops any +// leading zero bytes from num_blocks to save space on the wire. +type BlockHeightRange struct { + // FirstBlockHeight is the height of the earliest announcement message + // that should be sent by the receiver. + FirstBlockHeight uint32 + + // NumBlocks is the size of the window beyond FirstBlockHeight that + // announcements should be sent for. + NumBlocks uint32 +} + +// Record returns the TLV record used to encode/decode a BlockHeightRange. The +// type number is supplied by the wrapping RecordT. +func (b *BlockHeightRange) Record() tlv.Record { + sizeFunc := func() uint64 { + return 4 + tlv.SizeTUint32(b.NumBlocks) + } + + return tlv.MakeDynamicRecord( + 0, b, sizeFunc, blockHeightRangeEncoder, + blockHeightRangeDecoder, + ) +} + +func blockHeightRangeEncoder(w io.Writer, val interface{}, + buf *[8]byte) error { + + v, ok := val.(*BlockHeightRange) + if !ok { + return tlv.NewTypeForEncodingErr(val, "lnwire.BlockHeightRange") + } + + if err := tlv.EUint32T(w, v.FirstBlockHeight, buf); err != nil { + return err + } + + return tlv.ETUint32T(w, v.NumBlocks, buf) +} + +func blockHeightRangeDecoder(r io.Reader, val interface{}, buf *[8]byte, + l uint64) error { + + v, ok := val.(*BlockHeightRange) + if !ok || l < 4 { + return tlv.NewTypeForDecodingErr( + val, "lnwire.BlockHeightRange", l, 4, + ) + } + + if err := tlv.DUint32(r, &v.FirstBlockHeight, buf, 4); err != nil { + return err + } + + return tlv.DTUint32(r, &v.NumBlocks, buf, l-4) +} + // A compile time check to ensure GossipTimestampRange implements the // lnwire.Message interface. var _ Message = (*GossipTimestampRange)(nil) @@ -81,20 +136,14 @@ func (g *GossipTimestampRange) Decode(r io.Reader, _ uint32) error { return err } - var ( - firstBlock = tlv.ZeroRecordT[tlv.TlvType2, uint32]() - blockRange = tlv.ZeroRecordT[tlv.TlvType4, uint32]() - ) - typeMap, err := tlvRecords.ExtractRecords(&firstBlock, &blockRange) + bhRange := tlv.ZeroRecordT[tlv.TlvType2, BlockHeightRange]() + typeMap, err := tlvRecords.ExtractRecords(&bhRange) if err != nil { return err } - if val, ok := typeMap[g.FirstBlockHeight.TlvType()]; ok && val == nil { - g.FirstBlockHeight = tlv.SomeRecordT(firstBlock) - } - if val, ok := typeMap[g.BlockRange.TlvType()]; ok && val == nil { - g.BlockRange = tlv.SomeRecordT(blockRange) + if val, ok := typeMap[g.BlockHeightRange.TlvType()]; ok && val == nil { + g.BlockHeightRange = tlv.SomeRecordT(bhRange) } if len(tlvRecords) != 0 { @@ -121,15 +170,10 @@ func (g *GossipTimestampRange) Encode(w *bytes.Buffer, pver uint32) error { return err } - recordProducers := make([]tlv.RecordProducer, 0, 2) - g.FirstBlockHeight.WhenSome( - func(height tlv.RecordT[tlv.TlvType2, uint32]) { - recordProducers = append(recordProducers, &height) - }, - ) - g.BlockRange.WhenSome( - func(blockRange tlv.RecordT[tlv.TlvType4, uint32]) { - recordProducers = append(recordProducers, &blockRange) + recordProducers := make([]tlv.RecordProducer, 0, 1) + g.BlockHeightRange.WhenSome( + func(bhr tlv.RecordT[tlv.TlvType2, BlockHeightRange]) { + recordProducers = append(recordProducers, &bhr) }, ) err := EncodeMessageExtraData(&g.ExtraData, recordProducers...) diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 8734440a0f2..12511f2fca6 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -1283,22 +1283,20 @@ func (g *GossipTimestampRange) RandTestMessage(t *rapid.T) Message { ExtraData: RandExtraOpaqueData(t, nil), } - includeFirstBlockHeight := rapid.Bool().Draw( - t, "includeFirstBlockHeight", + includeBlockHeightRange := rapid.Bool().Draw( + t, "includeBlockHeightRange", ) - includeBlockRange := rapid.Bool().Draw(t, "includeBlockRange") - if includeFirstBlockHeight { + if includeBlockHeightRange { height := rapid.Uint32().Draw(t, "firstBlockHeight") - msg.FirstBlockHeight = tlv.SomeRecordT( - tlv.RecordT[tlv.TlvType2, uint32]{Val: height}, - ) - } - - if includeBlockRange { - blockRange := rapid.Uint32().Draw(t, "blockRange") - msg.BlockRange = tlv.SomeRecordT( - tlv.RecordT[tlv.TlvType4, uint32]{Val: blockRange}, + numBlocks := rapid.Uint32().Draw(t, "numBlocks") + msg.BlockHeightRange = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType2, BlockHeightRange]{ + Val: BlockHeightRange{ + FirstBlockHeight: height, + NumBlocks: numBlocks, + }, + }, ) } From 0cbe685e9220d70caab7bdca1d98a9f8e2c46c7b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:52:03 -0700 Subject: [PATCH 05/10] lnwire: add funding_txid TLV to announcement_signatures_2 The taproot-gossip BOLT extension added a required funding_txid TLV (type 6, sha256) to announcement_signatures_2 so each message names its funding transaction explicitly. For an initial channel open this is the original funding tx; for a spliced channel it is the txid of the splice transaction that triggered the new round of announcement signing. Using funding_txid directly (rather than inferring it via short_channel_id) is what makes splice announcements unambiguous when multiple candidate funding transactions may exist at different points. Add the field on AnnounceSignatures2 (encoded as [32]byte, to follow the same primitive-encoding pattern channel_announcement_2 uses for its chain hash), thread it through NewAnnSigs2, AllRecords and the Decode presence assertion, and update the test fixtures + the rapid property-test factory to set it. The two channeldb waitingproof tests that build AnnounceSignatures2 via NewAnnSigs2 are updated to pass a placeholder funding_txid. --- channeldb/waitingproof_test.go | 3 +++ lnwire/announcement_signatures_2.go | 25 ++++++++++++++++++++++-- lnwire/announcement_signatures_2_test.go | 7 +++++++ lnwire/test_message.go | 10 +++++++--- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/channeldb/waitingproof_test.go b/channeldb/waitingproof_test.go index 4b0e444168a..67f029fa8c3 100644 --- a/channeldb/waitingproof_test.go +++ b/channeldb/waitingproof_test.go @@ -109,6 +109,7 @@ func TestWaitingProofV2RoundTrip(t *testing.T) { lnwire.ChannelID{1, 2, 3}, lnwire.NewShortChanIDFromInt(42), partialSig, + [32]byte{1, 2, 3}, ) // Generate a deterministic public key for the combined nonce. @@ -208,6 +209,7 @@ func TestWaitingProofV2Store(t *testing.T) { lnwire.ChannelID{5, 6, 7}, lnwire.NewShortChanIDFromInt(100), partialSig, + [32]byte{5, 6, 7}, ) proof := NewV2WaitingProof(true, annSig2, pubKey) @@ -254,6 +256,7 @@ func TestWaitingProofCrossVersionKeyIsolation(t *testing.T) { lnwire.ChannelID{9, 9, 9}, scid, partialSig, + [32]byte{9, 9, 9}, ) v2Proof := NewV2WaitingProof(true, v2AnnSig, pubKey) diff --git a/lnwire/announcement_signatures_2.go b/lnwire/announcement_signatures_2.go index a2806f1ee2c..6019a45b25b 100644 --- a/lnwire/announcement_signatures_2.go +++ b/lnwire/announcement_signatures_2.go @@ -30,6 +30,13 @@ type AnnounceSignatures2 struct { // for the node's node ID key. PartialSignature tlv.RecordT[tlv.TlvType4, PartialSig] + // FundingTxID is the txid of the funding transaction that this + // announcement signature covers. For an initial channel announcement + // this is the original funding transaction; for a spliced channel it + // is the txid of the splice transaction whose splice_locked triggered + // the new round of announcement signing. + FundingTxID tlv.RecordT[tlv.TlvType6, [32]byte] + // Any extra fields in the signed range that we do not yet know about, // but we need to keep them for signature validation and to produce a // valid message. @@ -38,7 +45,7 @@ type AnnounceSignatures2 struct { // NewAnnSigs2 is a constructor for AnnounceSignatures2. func NewAnnSigs2(chanID ChannelID, scid ShortChannelID, - partialSig PartialSig) *AnnounceSignatures2 { + partialSig PartialSig, fundingTxID [32]byte) *AnnounceSignatures2 { return &AnnounceSignatures2{ ChannelID: tlv.NewRecordT[tlv.TlvType0, ChannelID](chanID), @@ -48,6 +55,9 @@ func NewAnnSigs2(chanID ChannelID, scid ShortChannelID, PartialSignature: tlv.NewRecordT[tlv.TlvType4, PartialSig]( partialSig, ), + FundingTxID: tlv.NewPrimitiveRecord[tlv.TlvType6, [32]byte]( + fundingTxID, + ), ExtraSignedFields: make(ExtraSignedFields), } } @@ -71,6 +81,7 @@ var _ PureTLVMessage = (*AnnounceSignatures2)(nil) func (a *AnnounceSignatures2) Decode(r io.Reader, _ uint32) error { stream, err := tlv.NewStream(ProduceRecordsSorted( &a.ChannelID, &a.ShortChannelID, &a.PartialSignature, + &a.FundingTxID, )...) if err != nil { return err @@ -81,6 +92,16 @@ func (a *AnnounceSignatures2) Decode(r io.Reader, _ uint32) error { return err } + if err := AssertRequiredPresent( + typeMap, + a.ChannelID.TlvType(), + a.ShortChannelID.TlvType(), + a.PartialSignature.TlvType(), + a.FundingTxID.TlvType(), + ); err != nil { + return err + } + a.ExtraSignedFields = ExtraSignedFieldsFromTypeMap(typeMap) return nil @@ -124,7 +145,7 @@ func (a *AnnounceSignatures2) SerializedSize() (uint32, error) { func (a *AnnounceSignatures2) AllRecords() []tlv.Record { recordProducers := []tlv.RecordProducer{ &a.ChannelID, &a.ShortChannelID, - &a.PartialSignature, + &a.PartialSignature, &a.FundingTxID, } recordProducers = append(recordProducers, RecordsAsProducers( diff --git a/lnwire/announcement_signatures_2_test.go b/lnwire/announcement_signatures_2_test.go index 6b945edcfc6..4780b2c18a8 100644 --- a/lnwire/announcement_signatures_2_test.go +++ b/lnwire/announcement_signatures_2_test.go @@ -39,6 +39,13 @@ func TestAnnSigs2EncodeDecode(t *testing.T) { }...) rawBytes = append(rawBytes, make([]byte, 32)...) // value + // FundingTxID. + rawBytes = append(rawBytes, []byte{ + 0x06, // type + 0x20, // length + }...) + rawBytes = append(rawBytes, make([]byte, 32)...) // value + // Extra field in the first signed range. rawBytes = append(rawBytes, []byte{ 0x30, // type diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 12511f2fca6..842313b041d 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -132,9 +132,10 @@ var _ TestMessage = (*AnnounceSignatures2)(nil) // This is part of the TestMessage interface. func (a *AnnounceSignatures2) RandTestMessage(t *rapid.T) Message { var ( - chanID = RandChannelID(t) - scid = RandShortChannelID(t) - pSig = RandPartialSig(t) + chanID = RandChannelID(t) + scid = RandShortChannelID(t) + pSig = RandPartialSig(t) + fundingTxID = RandChainHash(t) ) msg := &AnnounceSignatures2{ @@ -145,6 +146,9 @@ func (a *AnnounceSignatures2) RandTestMessage(t *rapid.T) Message { PartialSignature: tlv.NewRecordT[tlv.TlvType4, PartialSig]( *pSig, ), + FundingTxID: tlv.NewPrimitiveRecord[tlv.TlvType6, [32]byte]( + [32]byte(fundingTxID), + ), ExtraSignedFields: make(map[uint64][]byte), } From 8b6080bbb7fb0bd6f7bcedc58cbda48d1cdf8666 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 16:58:35 -0700 Subject: [PATCH 06/10] lnwire: assign inbound-fee TLVs proper types on channel_update_2 The taproot-gossip BOLT extension assigned the experimental inbound-fee field on channel_update_2 a real TLV layout: two separate uint32 records, type 20 (inbound_fee_base_msat) and type 22 (inbound_fee_proportional_millionths), both positive-only with a default of 0. Pull the implementation in line: - Replace the experimental InboundFee OptionalRecordT[TlvType55555, Fee] (which had a long-standing TODO to assign a real type) with two required uint32 RecordTs at types 20 and 22. - Suppress on encode when 0 and default-fill on decode, matching how the surrounding fee/htlc fields behave. - Update ChanEdgePolicyFromWire for ChannelUpdate2 to fold the two uint32 values into the existing fn.Option[lnwire.Fee] downstream contract: emit None when both are 0, Some otherwise. The Fee struct itself still uses int32 for the legacy ChannelUpdate1 case; ChannelUpdate2 inbound fees can only be non-negative so the uint32->int32 widening is safe in practice (and a v2 sender can't encode a negative fee anyway). - Update the channel_update_2 test fixture to carry valid type-20 and type-22 records, and move the previously-unknown extra TLV out of slot 20 to slot 24. - Update the rapid property factory to draw the two new uint32 fields from a non-zero range when including an inbound fee. --- graph/db/models/channel_edge_policy.go | 15 +++++++- lnwire/channel_update_2.go | 51 ++++++++++++++++++-------- lnwire/channel_update_2_test.go | 12 +++++- lnwire/test_message.go | 17 +++++---- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/graph/db/models/channel_edge_policy.go b/graph/db/models/channel_edge_policy.go index 067c7861a7a..ef7c5212efd 100644 --- a/graph/db/models/channel_edge_policy.go +++ b/graph/db/models/channel_edge_policy.go @@ -124,6 +124,19 @@ func ChanEdgePolicyFromWire(scid uint64, }, nil case *lnwire.ChannelUpdate2: + // Inbound fees in gossip v2 are two required uint32 TLVs that + // default to 0. Treat the both-zero case as "no inbound fee" + // so the downstream Option semantics still hold. + var inboundFee fn.Option[lnwire.Fee] + baseFee := upd.InboundFeeBaseMsat.Val + propFee := upd.InboundFeeProportionalMillionths.Val + if baseFee != 0 || propFee != 0 { + inboundFee = fn.Some(lnwire.Fee{ + BaseFee: int32(baseFee), + FeeRate: int32(propFee), + }) + } + return &ChannelEdgePolicy{ Version: lnwire.GossipVersion2, SigBytes: upd.Signature.Val.ToSignatureBytes(), @@ -140,7 +153,7 @@ func ChanEdgePolicyFromWire(scid uint64, FeeProportionalMillionths: lnwire.MilliSatoshi( upd.FeeProportionalMillionths.Val, ), - InboundFee: upd.InboundFee.ValOpt(), + InboundFee: inboundFee, ExtraSignedFields: upd.ExtraSignedFields, }, nil } diff --git a/lnwire/channel_update_2.go b/lnwire/channel_update_2.go index 8421c81da7b..feb638643d6 100644 --- a/lnwire/channel_update_2.go +++ b/lnwire/channel_update_2.go @@ -11,10 +11,12 @@ import ( ) const ( - defaultCltvExpiryDelta = uint16(80) - defaultHtlcMinMsat = MilliSatoshi(1) - defaultFeeBaseMsat = uint32(1000) - defaultFeeProportionalMillionths = uint32(1) + defaultCltvExpiryDelta = uint16(80) + defaultHtlcMinMsat = MilliSatoshi(1) + defaultFeeBaseMsat = uint32(1000) + defaultFeeProportionalMillionths = uint32(1) + defaultInboundFeeBaseMsat = uint32(0) + defaultInboundFeeProportionalMillionths = uint32(0) ) // ChannelUpdate2 message is used after taproot channel has been initially @@ -70,10 +72,16 @@ type ChannelUpdate2 struct { // millionth of a satoshi. FeeProportionalMillionths tlv.RecordT[tlv.TlvType18, uint32] - // InboundFee is an optional TLV record that contains the fee - // information for incoming HTLCs. - // TODO(elle): assign normal tlv type? - InboundFee tlv.OptionalRecordT[tlv.TlvType55555, Fee] + // InboundFeeBaseMsat is the base fee (in millisatoshis) added by this + // node for HTLCs forwarded *in* via this channel, regardless of which + // channel they are forwarded out on. Default 0. Positive-only: this + // version of gossip does not support negative inbound fees. + InboundFeeBaseMsat tlv.RecordT[tlv.TlvType20, uint32] + + // InboundFeeProportionalMillionths is the proportional inbound fee (in + // millionths of a satoshi) added by this node per transferred satoshi + // for HTLCs forwarded *in* via this channel. Default 0. Positive-only. + InboundFeeProportionalMillionths tlv.RecordT[tlv.TlvType22, uint32] // Signature is used to validate the announced data and prove the // ownership of node id. @@ -114,13 +122,13 @@ func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error { var ( chainHash = tlv.ZeroRecordT[tlv.TlvType0, [32]byte]() secondPeer = tlv.ZeroRecordT[tlv.TlvType8, TrueBoolean]() - inboundFee = tlv.ZeroRecordT[tlv.TlvType55555, Fee]() ) typeMap, err := tlvRecords.ExtractRecords( &chainHash, &c.ShortChannelID, &c.BlockHeight, &c.DisabledFlags, &secondPeer, &c.CLTVExpiryDelta, &c.HTLCMinimumMsat, &c.HTLCMaximumMsat, &c.FeeBaseMsat, - &c.FeeProportionalMillionths, &inboundFee, + &c.FeeProportionalMillionths, + &c.InboundFeeBaseMsat, &c.InboundFeeProportionalMillionths, &c.Signature, ) if err != nil { @@ -171,9 +179,14 @@ func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error { c.FeeProportionalMillionths.Val = defaultFeeProportionalMillionths //nolint:ll } - // If the inbound fee was encoded, set it. - if _, ok := typeMap[c.InboundFee.TlvType()]; ok { - c.InboundFee = tlv.SomeRecordT(inboundFee) + // If the inbound base fee was not encoded, default to 0. + if _, ok := typeMap[c.InboundFeeBaseMsat.TlvType()]; !ok { + c.InboundFeeBaseMsat.Val = defaultInboundFeeBaseMsat + } + + // If the inbound proportional fee was not encoded, default to 0. + if _, ok := typeMap[c.InboundFeeProportionalMillionths.TlvType()]; !ok { + c.InboundFeeProportionalMillionths.Val = defaultInboundFeeProportionalMillionths //nolint:ll } c.ExtraSignedFields = ExtraSignedFieldsFromTypeMap(typeMap) @@ -234,9 +247,15 @@ func (c *ChannelUpdate2) AllRecords() []tlv.Record { ) } - c.InboundFee.WhenSome(func(r tlv.RecordT[tlv.TlvType55555, Fee]) { - recordProducers = append(recordProducers, &r) - }) + if c.InboundFeeBaseMsat.Val != defaultInboundFeeBaseMsat { + recordProducers = append(recordProducers, &c.InboundFeeBaseMsat) + } + + if c.InboundFeeProportionalMillionths.Val != defaultInboundFeeProportionalMillionths { //nolint:ll + recordProducers = append( + recordProducers, &c.InboundFeeProportionalMillionths, + ) + } recordProducers = append(recordProducers, RecordsAsProducers( tlv.MapToRecords(c.ExtraSignedFields), diff --git a/lnwire/channel_update_2_test.go b/lnwire/channel_update_2_test.go index 1f53ec5f328..30cd66c0088 100644 --- a/lnwire/channel_update_2_test.go +++ b/lnwire/channel_update_2_test.go @@ -73,8 +73,18 @@ func TestChanUpdate2EncodeDecode(t *testing.T) { 0x4, // length. 0x0, 0x0, 0x1, 0x0, // value. + // InboundFeeBaseMsat record. + 0x14, // type. + 0x4, // length. + 0x0, 0x0, 0x0, 0x5, // value (5). + + // InboundFeeProportionalMillionths record. + 0x16, // type. + 0x4, // length. + 0x0, 0x0, 0x0, 0x3, // value (3). + // Extra Opaque Data - Unknown Record. - 0x14, // type. + 0x18, // type. 0x2, // length. 0x79, 0x79, // value. diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 842313b041d..5ef06d0021c 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -600,15 +600,16 @@ func (c *ChannelUpdate2) RandTestMessage(t *rapid.T) Message { } if rapid.Bool().Draw(t, "includeInboundFee") { - base := rapid.IntRange(-1000, 1000).Draw(t, "inFeeBase") - rate := rapid.IntRange(-1000, 1000).Draw(t, "inFeeProp") - fee := Fee{ - BaseFee: int32(base), - FeeRate: int32(rate), - } - msg.InboundFee = tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType55555](fee), + base := uint32( + rapid.IntRange(1, 0x7FFFFFFF).Draw(t, "inFeeBase"), + ) + rate := uint32( + rapid.IntRange(1, 0x7FFFFFFF).Draw(t, "inFeeProp"), ) + msg.InboundFeeBaseMsat = + tlv.NewPrimitiveRecord[tlv.TlvType20](base) + msg.InboundFeeProportionalMillionths = + tlv.NewPrimitiveRecord[tlv.TlvType22](rate) } msg.Signature.Val = RandSignature(t) From 4012192a4e606bddba3d93cfab6810a60b348c7d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 17:02:42 -0700 Subject: [PATCH 07/10] lnwire: carry two raw musig2 partial sigs in announcement_signatures_2 The taproot-gossip BOLT extension dropped the previously pre-aggregated 32-byte partial signature on announcement_signatures_2 in favour of emitting both raw musig2 partial sigs back-to-back -- one for the node_id key and one for the bitcoin key -- so the receiver can verify each half with the standard MuSig2 PartialSigVerify routine instead of the custom verifier the old layout required. Introduce an AnnouncementSigPair value type in partial_sig.go that encodes as `node || bitcoin` for a fixed 64 bytes, with a static- record builder via tlv.MakeStaticRecord. Swap announcement_signatures_2's PartialSignature (TlvType4, PartialSig) field for a new PartialSignatures (TlvType4, AnnouncementSigPair) field; update NewAnnSigs2 to take the pair; update the hardcoded test fixture (length 0x20 -> 0x40, 32 bytes -> 64 bytes of zero padding); update the rapid property factory to draw two independent partial sigs; and update the three channeldb waitingproof tests that build AnnounceSignatures2 directly. The existing 32-byte PartialSig type stays in place for the co-operative close flow and other call-sites that don't carry both sigs at once. --- channeldb/waitingproof_test.go | 6 +- lnwire/announcement_signatures_2.go | 25 ++++--- lnwire/announcement_signatures_2_test.go | 6 +- lnwire/partial_sig.go | 85 ++++++++++++++++++++++++ lnwire/test_message.go | 11 +-- 5 files changed, 112 insertions(+), 21 deletions(-) diff --git a/channeldb/waitingproof_test.go b/channeldb/waitingproof_test.go index 67f029fa8c3..dce8ffb5658 100644 --- a/channeldb/waitingproof_test.go +++ b/channeldb/waitingproof_test.go @@ -108,7 +108,7 @@ func TestWaitingProofV2RoundTrip(t *testing.T) { annSig2 := lnwire.NewAnnSigs2( lnwire.ChannelID{1, 2, 3}, lnwire.NewShortChanIDFromInt(42), - partialSig, + lnwire.NewAnnouncementSigPair(partialSig.Sig, partialSig.Sig), [32]byte{1, 2, 3}, ) @@ -208,7 +208,7 @@ func TestWaitingProofV2Store(t *testing.T) { annSig2 := lnwire.NewAnnSigs2( lnwire.ChannelID{5, 6, 7}, lnwire.NewShortChanIDFromInt(100), - partialSig, + lnwire.NewAnnouncementSigPair(partialSig.Sig, partialSig.Sig), [32]byte{5, 6, 7}, ) @@ -255,7 +255,7 @@ func TestWaitingProofCrossVersionKeyIsolation(t *testing.T) { v2AnnSig := lnwire.NewAnnSigs2( lnwire.ChannelID{9, 9, 9}, scid, - partialSig, + lnwire.NewAnnouncementSigPair(partialSig.Sig, partialSig.Sig), [32]byte{9, 9, 9}, ) v2Proof := NewV2WaitingProof(true, v2AnnSig, pubKey) diff --git a/lnwire/announcement_signatures_2.go b/lnwire/announcement_signatures_2.go index 6019a45b25b..101e7af1cbc 100644 --- a/lnwire/announcement_signatures_2.go +++ b/lnwire/announcement_signatures_2.go @@ -25,10 +25,12 @@ type AnnounceSignatures2 struct { // index which pays to the channel. ShortChannelID tlv.RecordT[tlv.TlvType2, ShortChannelID] - // PartialSignature is the combination of the partial Schnorr signature - // created for the node's bitcoin key with the partial signature created - // for the node's node ID key. - PartialSignature tlv.RecordT[tlv.TlvType4, PartialSig] + // PartialSignatures carries the two raw musig2 partial signatures + // produced by the sender (one with its node_id key, one with its + // bitcoin key), concatenated as `node || bitcoin` (64 bytes total). + // The receiver verifies each half independently with the standard + // MuSig2 partial-sig verify routine. + PartialSignatures tlv.RecordT[tlv.TlvType4, AnnouncementSigPair] // FundingTxID is the txid of the funding transaction that this // announcement signature covers. For an initial channel announcement @@ -45,16 +47,17 @@ type AnnounceSignatures2 struct { // NewAnnSigs2 is a constructor for AnnounceSignatures2. func NewAnnSigs2(chanID ChannelID, scid ShortChannelID, - partialSig PartialSig, fundingTxID [32]byte) *AnnounceSignatures2 { + sigs AnnouncementSigPair, + fundingTxID [32]byte) *AnnounceSignatures2 { return &AnnounceSignatures2{ ChannelID: tlv.NewRecordT[tlv.TlvType0, ChannelID](chanID), ShortChannelID: tlv.NewRecordT[tlv.TlvType2, ShortChannelID]( scid, ), - PartialSignature: tlv.NewRecordT[tlv.TlvType4, PartialSig]( - partialSig, - ), + PartialSignatures: tlv.NewRecordT[ + tlv.TlvType4, AnnouncementSigPair, + ](sigs), FundingTxID: tlv.NewPrimitiveRecord[tlv.TlvType6, [32]byte]( fundingTxID, ), @@ -80,7 +83,7 @@ var _ PureTLVMessage = (*AnnounceSignatures2)(nil) // This is part of the lnwire.Message interface. func (a *AnnounceSignatures2) Decode(r io.Reader, _ uint32) error { stream, err := tlv.NewStream(ProduceRecordsSorted( - &a.ChannelID, &a.ShortChannelID, &a.PartialSignature, + &a.ChannelID, &a.ShortChannelID, &a.PartialSignatures, &a.FundingTxID, )...) if err != nil { @@ -96,7 +99,7 @@ func (a *AnnounceSignatures2) Decode(r io.Reader, _ uint32) error { typeMap, a.ChannelID.TlvType(), a.ShortChannelID.TlvType(), - a.PartialSignature.TlvType(), + a.PartialSignatures.TlvType(), a.FundingTxID.TlvType(), ); err != nil { return err @@ -145,7 +148,7 @@ func (a *AnnounceSignatures2) SerializedSize() (uint32, error) { func (a *AnnounceSignatures2) AllRecords() []tlv.Record { recordProducers := []tlv.RecordProducer{ &a.ChannelID, &a.ShortChannelID, - &a.PartialSignature, &a.FundingTxID, + &a.PartialSignatures, &a.FundingTxID, } recordProducers = append(recordProducers, RecordsAsProducers( diff --git a/lnwire/announcement_signatures_2_test.go b/lnwire/announcement_signatures_2_test.go index 4780b2c18a8..bd1b394e066 100644 --- a/lnwire/announcement_signatures_2_test.go +++ b/lnwire/announcement_signatures_2_test.go @@ -32,12 +32,12 @@ func TestAnnSigs2EncodeDecode(t *testing.T) { 0, 0, 1, 0, 0, 2, 0, 3, // value }...) - // PartialSignature. + // PartialSignatures (node || bitcoin, 64 bytes total). rawBytes = append(rawBytes, []byte{ 0x04, // type - 0x20, // length + 0x40, // length }...) - rawBytes = append(rawBytes, make([]byte, 32)...) // value + rawBytes = append(rawBytes, make([]byte, 64)...) // value // FundingTxID. rawBytes = append(rawBytes, []byte{ diff --git a/lnwire/partial_sig.go b/lnwire/partial_sig.go index d5af8ec9024..582951fdae8 100644 --- a/lnwire/partial_sig.go +++ b/lnwire/partial_sig.go @@ -249,3 +249,88 @@ func MaybePartialSigWithNonce(sig *PartialSigWithNonce, ), ) } + +const ( + // AnnouncementSigPairLen is the wire length of an AnnouncementSigPair: + // two concatenated 32-byte musig2 partial signatures. + AnnouncementSigPairLen = 64 +) + +// AnnouncementSigPair carries the two raw musig2 partial signatures that a +// node emits in announcement_signatures_2 -- one over its node_id key and one +// over its bitcoin key. They are encoded back-to-back as `node || bitcoin` +// for a total of 64 bytes. The BOLT taproot-gossip extension dropped the +// previously pre-aggregated 32-byte form in favour of this layout so that +// receivers can verify each sig with the standard MuSig2 partial-sig verify +// routine. +type AnnouncementSigPair struct { + // Node is the partial signature produced with the node_id key. + Node btcec.ModNScalar + + // Bitcoin is the partial signature produced with the bitcoin key. + Bitcoin btcec.ModNScalar +} + +// NewAnnouncementSigPair creates a new AnnouncementSigPair. +func NewAnnouncementSigPair(node, + bitcoin btcec.ModNScalar) AnnouncementSigPair { + + return AnnouncementSigPair{ + Node: node, + Bitcoin: bitcoin, + } +} + +// Record returns the tlv record for the announcement-sig pair. The wrapping +// RecordT supplies the real type number; the zero passed here is overridden. +func (a *AnnouncementSigPair) Record() tlv.Record { + return tlv.MakeStaticRecord( + 0, a, AnnouncementSigPairLen, + announcementSigPairEncoder, announcementSigPairDecoder, + ) +} + +func announcementSigPairEncoder(w io.Writer, val interface{}, + _ *[8]byte) error { + + v, ok := val.(*AnnouncementSigPair) + if !ok { + return tlv.NewTypeForEncodingErr( + val, "lnwire.AnnouncementSigPair", + ) + } + + nodeBytes := v.Node.Bytes() + if _, err := w.Write(nodeBytes[:]); err != nil { + return err + } + bitcoinBytes := v.Bitcoin.Bytes() + _, err := w.Write(bitcoinBytes[:]) + + return err +} + +func announcementSigPairDecoder(r io.Reader, val interface{}, buf *[8]byte, + l uint64) error { + + v, ok := val.(*AnnouncementSigPair) + if !ok || l != AnnouncementSigPairLen { + return tlv.NewTypeForDecodingErr( + val, "lnwire.AnnouncementSigPair", l, + AnnouncementSigPairLen, + ) + } + + var nodeBytes, bitcoinBytes [32]byte + if err := tlv.DBytes32(r, &nodeBytes, buf, 32); err != nil { + return err + } + if err := tlv.DBytes32(r, &bitcoinBytes, buf, 32); err != nil { + return err + } + + v.Node.SetBytes(&nodeBytes) + v.Bitcoin.SetBytes(&bitcoinBytes) + + return nil +} diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 5ef06d0021c..8ead7ed3491 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -134,18 +134,21 @@ func (a *AnnounceSignatures2) RandTestMessage(t *rapid.T) Message { var ( chanID = RandChannelID(t) scid = RandShortChannelID(t) - pSig = RandPartialSig(t) + nodeSig = RandPartialSig(t) + bitcoinSig = RandPartialSig(t) fundingTxID = RandChainHash(t) ) + sigs := NewAnnouncementSigPair(nodeSig.Sig, bitcoinSig.Sig) + msg := &AnnounceSignatures2{ ChannelID: tlv.NewRecordT[tlv.TlvType0, ChannelID]( chanID, ), ShortChannelID: tlv.NewRecordT[tlv.TlvType2](scid), - PartialSignature: tlv.NewRecordT[tlv.TlvType4, PartialSig]( - *pSig, - ), + PartialSignatures: tlv.NewRecordT[ + tlv.TlvType4, AnnouncementSigPair, + ](sigs), FundingTxID: tlv.NewPrimitiveRecord[tlv.TlvType6, [32]byte]( [32]byte(fundingTxID), ), From df6b0aacc95eb912cf26687171e0ab3b5a95a7f7 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 17:05:55 -0700 Subject: [PATCH 08/10] graph/db/models: clarify inbound-fee comment in ChanEdgePolicyFromWire The channel_update_2 inbound-fee TLVs are not "required" -- they follow the same defaulted-on-the-wire pattern as fee_base_msat and friends, where the field is always present in the Go struct (a uint32 with the default-fill applied on decode) and suppressed from the wire when it equals the default of 0. Reword the comment to avoid implying that a sender MUST emit them. No code change. --- graph/db/models/channel_edge_policy.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graph/db/models/channel_edge_policy.go b/graph/db/models/channel_edge_policy.go index ef7c5212efd..e494f7b7807 100644 --- a/graph/db/models/channel_edge_policy.go +++ b/graph/db/models/channel_edge_policy.go @@ -124,9 +124,11 @@ func ChanEdgePolicyFromWire(scid uint64, }, nil case *lnwire.ChannelUpdate2: - // Inbound fees in gossip v2 are two required uint32 TLVs that - // default to 0. Treat the both-zero case as "no inbound fee" - // so the downstream Option semantics still hold. + // Inbound fees in gossip v2 are two uint32 TLVs that are + // suppressed on the wire when they take their default value + // of 0 (i.e. no inbound surcharge). Treat the both-zero case + // as "no inbound fee" so the downstream Option semantics + // still hold. var inboundFee fn.Option[lnwire.Fee] baseFee := upd.InboundFeeBaseMsat.Val propFee := upd.InboundFeeProportionalMillionths.Val From 856f39f09e91104bab90ddaa48b0779dc1c1160b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 22 May 2026 17:26:00 -0700 Subject: [PATCH 09/10] lnwire: encode channel_update_2 direction in short_channel_id via sciddir The taproot-gossip BOLT extension switched channel_update_2.short_channel_id from a plain 8-byte SCID to BOLT 1's sciddir_or_pubkey type constrained to the sciddir form: a 9-byte encoding where the direction byte is 0 for node_id_1 and 1 for node_id_2. With the direction now part of the scid itself, the previously-separate type-8 second_peer flag TLV is fully redundant and is removed. Add a Sciddir value type in lnwire/sciddir.go with a 9-byte static record (custom encoder/decoder that rejects any direction byte other than 0 or 1, so we can never accidentally accept the pubkey form of sciddir_or_pubkey here). In ChannelUpdate2: * ShortChannelID now wraps a Sciddir instead of a ShortChannelID. * The SecondPeer OptionalRecordT and all its Decode/AllRecords wiring is removed. * IsNode1() now derives from the dir byte (`Direction == 0`). * SCID() projects the 8-byte scid portion so the ChannelUpdate interface stays unchanged for callers. * SetSCID() updates the scid portion while leaving the existing dir byte in place. ChanEdgePolicyFromWire now derives ChannelEdgePolicy.SecondPeer from !upd.IsNode1() instead of the old upd.SecondPeer.IsSome() lookup. The downstream SecondPeer field on ChannelEdgePolicy stays the same shape since it is also used by ChannelUpdate1. The channel_update_2 test fixture now carries a 9-byte sciddir at type 2 and no longer has a type-8 SecondPeer record. The rapid property factory draws a single bool for the direction byte instead of separately drawing an isSecondPeer flag for the (now removed) second_peer field. --- graph/db/models/channel_edge_policy.go | 2 +- lnwire/channel_update_2.go | 46 +++++------ lnwire/channel_update_2_test.go | 11 +-- lnwire/sciddir.go | 101 +++++++++++++++++++++++++ lnwire/test_message.go | 13 ++-- 5 files changed, 129 insertions(+), 44 deletions(-) create mode 100644 lnwire/sciddir.go diff --git a/graph/db/models/channel_edge_policy.go b/graph/db/models/channel_edge_policy.go index e494f7b7807..8bb7922ef96 100644 --- a/graph/db/models/channel_edge_policy.go +++ b/graph/db/models/channel_edge_policy.go @@ -144,7 +144,7 @@ func ChanEdgePolicyFromWire(scid uint64, SigBytes: upd.Signature.Val.ToSignatureBytes(), ChannelID: scid, LastBlockHeight: upd.BlockHeight.Val, - SecondPeer: upd.SecondPeer.IsSome(), + SecondPeer: !upd.IsNode1(), DisableFlags: upd.DisabledFlags.Val, TimeLockDelta: upd.CLTVExpiryDelta.Val, MinHTLC: upd.HTLCMinimumMsat.Val, diff --git a/lnwire/channel_update_2.go b/lnwire/channel_update_2.go index feb638643d6..964e3859250 100644 --- a/lnwire/channel_update_2.go +++ b/lnwire/channel_update_2.go @@ -30,8 +30,13 @@ type ChannelUpdate2 struct { // channel globally in a blockchain. ChainHash tlv.RecordT[tlv.TlvType0, chainhash.Hash] - // ShortChannelID is the unique description of the funding transaction. - ShortChannelID tlv.RecordT[tlv.TlvType2, ShortChannelID] + // ShortChannelID identifies the channel and the side of it that sent + // this update. It is BOLT 1's `sciddir_or_pubkey` type constrained to + // the `sciddir` form: 9 wire bytes of ``, where the + // direction byte is `0` for `node_id_1` and `1` for `node_id_2`. The + // previous separate `second_peer` flag TLV at type 8 is no longer + // emitted; its information is now carried by the direction byte. + ShortChannelID tlv.RecordT[tlv.TlvType2, Sciddir] // BlockHeight allows ordering in the case of multiple announcements. We // should ignore the message if block height is not greater than the @@ -45,11 +50,6 @@ type ChannelUpdate2 struct { // disabled. DisabledFlags tlv.RecordT[tlv.TlvType6, ChanUpdateDisableFlags] - // SecondPeer is used to indicate which node the channel node has - // created and signed this message. If this field is present, it was - // node 2 otherwise it was node 1. - SecondPeer tlv.OptionalRecordT[tlv.TlvType8, TrueBoolean] - // CLTVExpiryDelta is the minimum number of blocks this node requires to // be added to the expiry of HTLCs. This is a security parameter // determined by the node operator. This value represents the required @@ -119,13 +119,10 @@ func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error { return err } - var ( - chainHash = tlv.ZeroRecordT[tlv.TlvType0, [32]byte]() - secondPeer = tlv.ZeroRecordT[tlv.TlvType8, TrueBoolean]() - ) + chainHash := tlv.ZeroRecordT[tlv.TlvType0, [32]byte]() typeMap, err := tlvRecords.ExtractRecords( &chainHash, &c.ShortChannelID, &c.BlockHeight, &c.DisabledFlags, - &secondPeer, &c.CLTVExpiryDelta, &c.HTLCMinimumMsat, + &c.CLTVExpiryDelta, &c.HTLCMinimumMsat, &c.HTLCMaximumMsat, &c.FeeBaseMsat, &c.FeeProportionalMillionths, &c.InboundFeeBaseMsat, &c.InboundFeeProportionalMillionths, @@ -151,11 +148,6 @@ func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error { c.ChainHash.Val = chainHash.Val } - // The presence of the second_peer tlv type indicates "true". - if _, ok := typeMap[c.SecondPeer.TlvType()]; ok { - c.SecondPeer = tlv.SomeRecordT(secondPeer) - } - // If the CLTV expiry delta was not encoded, then set it to the default // value. if _, ok := typeMap[c.CLTVExpiryDelta.TlvType()]; !ok { @@ -220,11 +212,6 @@ func (c *ChannelUpdate2) AllRecords() []tlv.Record { recordProducers = append(recordProducers, &c.DisabledFlags) } - // We only need to encode the second peer boolean if it is true - c.SecondPeer.WhenSome(func(r tlv.RecordT[tlv.TlvType8, TrueBoolean]) { - recordProducers = append(recordProducers, &r) - }) - // We only encode the cltv expiry delta if it is not equal to the // default. if c.CLTVExpiryDelta.Val != defaultCltvExpiryDelta { @@ -287,19 +274,21 @@ var _ Message = (*ChannelUpdate2)(nil) // lnwire.PureTLVMessage interface. var _ PureTLVMessage = (*ChannelUpdate2)(nil) -// SCID returns the ShortChannelID of the channel that the update applies to. +// SCID returns the ShortChannelID of the channel that the update applies to, +// projecting away the direction byte that the wire encoding carries. // // NOTE: this is part of the ChannelUpdate interface. func (c *ChannelUpdate2) SCID() ShortChannelID { - return c.ShortChannelID.Val + return c.ShortChannelID.Val.ID } // IsNode1 is true if the update was produced by node 1 of the channel peers. -// Node 1 is the node with the lexicographically smaller public key. +// Node 1 is the node with the lexicographically smaller public key, and is +// indicated by a direction byte of 0 in the encoded ShortChannelID. // // NOTE: this is part of the ChannelUpdate interface. func (c *ChannelUpdate2) IsNode1() bool { - return c.SecondPeer.IsNone() + return c.ShortChannelID.Val.IsNode1() } // IsDisabled is true if the update is announcing that the channel should be @@ -366,11 +355,12 @@ func (c *ChannelUpdate2) SetDisabledFlag(disabled bool) { } } -// SetSCID can be used to overwrite the SCID of the update. +// SetSCID can be used to overwrite the SCID of the update, leaving the +// existing direction byte in place. // // NOTE: this is part of the ChannelUpdate interface. func (c *ChannelUpdate2) SetSCID(scid ShortChannelID) { - c.ShortChannelID.Val = scid + c.ShortChannelID.Val.ID = scid } // A compile time check to ensure ChannelUpdate2 implements the diff --git a/lnwire/channel_update_2_test.go b/lnwire/channel_update_2_test.go index 30cd66c0088..189dcc1aebd 100644 --- a/lnwire/channel_update_2_test.go +++ b/lnwire/channel_update_2_test.go @@ -24,10 +24,11 @@ func TestChanUpdate2EncodeDecode(t *testing.T) { 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, - // ShortChannelID record. + // ShortChannelID record (sciddir form: dir byte + 8-byte scid). 0x2, // type. - 0x8, // length. - 0x0, 0x0, 0x1, 0x0, 0x0, 0x2, 0x0, 0x3, // value. + 0x9, // length. + 0x1, // dir byte: node_id_2. + 0x0, 0x0, 0x1, 0x0, 0x0, 0x2, 0x0, 0x3, // scid value. // BlockHeight record. 0x4, // type. @@ -39,10 +40,6 @@ func TestChanUpdate2EncodeDecode(t *testing.T) { 0x1, // length. 0x1, // value. - // SecondPeer record. - 0x8, // type. - 0x0, // length. - // Unknown odd-type TLV record. 0x9, // type. 0x2, // length. diff --git a/lnwire/sciddir.go b/lnwire/sciddir.go new file mode 100644 index 00000000000..ba8bf4a2afb --- /dev/null +++ b/lnwire/sciddir.go @@ -0,0 +1,101 @@ +package lnwire + +import ( + "fmt" + "io" + + "github.com/lightningnetwork/lnd/tlv" +) + +// SciddirLen is the wire length of a Sciddir: one direction byte followed by +// the 8-byte short_channel_id. +const SciddirLen = 9 + +// Sciddir is the `sciddir` form of BOLT 1's `sciddir_or_pubkey` type: a +// channel-with-direction identifier carried on the wire as +// `` for 9 bytes total. The leading byte must be +// `0` (refers to node_id_1 of the corresponding channel_announcement_2) or +// `1` (refers to node_id_2). The `pubkey` form of `sciddir_or_pubkey` is +// rejected wherever a Sciddir is used. +type Sciddir struct { + // Direction is the direction byte. `0` means the message comes from + // (or refers to) `node_id_1` of the channel announcement; `1` means + // `node_id_2`. + Direction byte + + // ID is the 8-byte short_channel_id portion. + ID ShortChannelID +} + +// NewSciddir builds a Sciddir from a short_channel_id and an `isSecondPeer` +// flag — true if the message comes from `node_id_2`. +func NewSciddir(scid ShortChannelID, isSecondPeer bool) Sciddir { + var dir byte + if isSecondPeer { + dir = 1 + } + + return Sciddir{ + Direction: dir, + ID: scid, + } +} + +// IsNode1 reports whether the direction byte refers to `node_id_1` (i.e. +// `dirbyte == 0`). +func (s *Sciddir) IsNode1() bool { + return s.Direction == 0 +} + +// Record returns the tlv record used to encode a Sciddir on the wire. The +// wrapping RecordT supplies the actual TLV type number; the zero passed here +// is overridden. +func (s *Sciddir) Record() tlv.Record { + return tlv.MakeStaticRecord( + 0, s, SciddirLen, sciddirEncoder, sciddirDecoder, + ) +} + +func sciddirEncoder(w io.Writer, val interface{}, buf *[8]byte) error { + v, ok := val.(*Sciddir) + if !ok { + return tlv.NewTypeForEncodingErr(val, "lnwire.Sciddir") + } + + if v.Direction != 0 && v.Direction != 1 { + return fmt.Errorf("sciddir direction byte must be 0 or 1, "+ + "got %d", v.Direction) + } + + if _, err := w.Write([]byte{v.Direction}); err != nil { + return err + } + + return EShortChannelID(w, &v.ID, buf) +} + +func sciddirDecoder(r io.Reader, val interface{}, buf *[8]byte, + l uint64) error { + + v, ok := val.(*Sciddir) + if !ok || l != SciddirLen { + return tlv.NewTypeForDecodingErr(val, "lnwire.Sciddir", l, + SciddirLen) + } + + var dir [1]byte + if _, err := io.ReadFull(r, dir[:]); err != nil { + return err + } + + // Constrain to the sciddir form. A first byte of anything other than + // 0 or 1 would indicate the pubkey form of sciddir_or_pubkey, which is + // not permitted in this context. + if dir[0] != 0 && dir[0] != 1 { + return fmt.Errorf("expected sciddir form of sciddir_or_pubkey "+ + "(direction byte 0 or 1), got %d", dir[0]) + } + v.Direction = dir[0] + + return DShortChannelID(r, &v.ID, buf, 8) +} diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 8ead7ed3491..b4e93ea747c 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -570,13 +570,16 @@ func (c *ChannelUpdate2) RandTestMessage(t *rapid.T) Message { var chainHashObj chainhash.Hash copy(chainHashObj[:], chainHash[:]) + isSecondPeer := rapid.Bool().Draw(t, "isSecondPeer") + sciddir := NewSciddir(shortChanID, isSecondPeer) + //nolint:ll msg := &ChannelUpdate2{ ChainHash: tlv.NewPrimitiveRecord[tlv.TlvType0, chainhash.Hash]( chainHashObj, ), - ShortChannelID: tlv.NewRecordT[tlv.TlvType2, ShortChannelID]( - shortChanID, + ShortChannelID: tlv.NewRecordT[tlv.TlvType2, Sciddir]( + sciddir, ), BlockHeight: tlv.NewPrimitiveRecord[tlv.TlvType4, uint32]( blockHeight, @@ -618,12 +621,6 @@ func (c *ChannelUpdate2) RandTestMessage(t *rapid.T) Message { msg.Signature.Val = RandSignature(t) msg.Signature.Val.ForceSchnorr() - if rapid.Bool().Draw(t, "isSecondPeer") { - msg.SecondPeer = tlv.SomeRecordT( - tlv.RecordT[tlv.TlvType8, TrueBoolean]{}, - ) - } - return msg } From 64acef0031c68f4e261491e87d35c52c14fdd8e3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Sun, 24 May 2026 12:57:36 -0700 Subject: [PATCH 10/10] docs/release-notes: note gossip v2 wire-format alignment with BOLT taproot-gossip The branch behind this PR pulls the lnwire gossip v2 messages in line with the review-driven updates on the BOLT taproot-gossip extension (lightning/bolts#1059). Record the change in the 0.22.0 release notes under "BOLT Spec Updates" so the PR check is satisfied and downstream implementors get a heads-up about the wire-format shifts. --- docs/release-notes/release-notes-0.22.0.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 45e76051922..21451e0e9cd 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -77,6 +77,24 @@ later in the reservation flow as a funder-balance-dust error; they now surface a clearer, spec-aligned error string up front. +* The gossip v2 wire messages in `lnwire` + ([#10837](https://github.com/lightningnetwork/lnd/pull/10837)) have been + pulled in line with the BOLT taproot-gossip extension + ([lightning/bolts#1059](https://github.com/lightning/bolts/pull/1059)): + signature TLVs move from type 160 to 240 (with the signed TLV range + widening from `0..=159` to `0..=239` to match BOLT 12); each gossip v2 + reader now rejects messages missing any compulsory field; the + port-not-zero rule on `node_announcement_2` now also covers + `tor_v3_address`; `gossip_timestamp_filter`'s two block-height TLVs + collapse into a single `block_height_range` TLV; + `announcement_signatures_2` gains a required `funding_txid` TLV and + now carries two raw MuSig2 partial signatures (64 bytes) rather than a + single pre-aggregated 32-byte value; `channel_update_2`'s experimental + inbound-fee TLV is replaced with two properly typed `tu32` records at + types 20/22, and its `short_channel_id` now uses the `sciddir` form of + BOLT 1's `sciddir_or_pubkey` (the previous `second_peer` TLV is gone, + with direction folded into the leading dir byte). + ## Testing ## Database