From 44406db82a65b3acc6bbb2ca0c6dfd75b6c8957d Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Thu, 28 Aug 2025 13:35:43 +0200 Subject: [PATCH 1/8] lnwallet: introduce AuxChannelNegotiator interface We introduce this new interface with the purpose of injecting and handling custom records on the init message, and also notifying external components when receiving the ChannelReady or ChannelReestablish message. --- lnwallet/aux_negotiator.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 lnwallet/aux_negotiator.go diff --git a/lnwallet/aux_negotiator.go b/lnwallet/aux_negotiator.go new file mode 100644 index 0000000000..73096a7d0c --- /dev/null +++ b/lnwallet/aux_negotiator.go @@ -0,0 +1,34 @@ +package lnwallet + +import ( + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" +) + +// AuxChannelNegotiator is an interface that allows aux channel implementations +// to inject or handle custom records in the init message that is used when +// establishing a connection with a peer. It may also notify the aux channel +// implementation for the channel ready or channel reestablish events, which +// mark the channel as ready to use. +type AuxChannelNegotiator interface { + // GetInitRecords is called when sending an init message to a peer. + // It returns custom records to include in the init message TLVs. The + // implementation can decide which records to include based on the peer + // identity. + GetInitRecords(peer route.Vertex) (lnwire.CustomRecords, error) + + // ProcessInitRecords handles received init records from a peer. The + // implementation can store state internally to affect future + // channel operations with this peer. + ProcessInitRecords(peer route.Vertex, + customRecords lnwire.CustomRecords) error + + // ProcessChannelReady handles the event of marking a channel identified + // by its channel ID as ready to use. We also provide the peer the + // channel was established with. + ProcessChannelReady(cid lnwire.ChannelID, peer route.Vertex) + + // ProcessReestablish handles the received channel_reestablish message + // which marks a channel identified by its cid as ready to use again. + ProcessReestablish(cid lnwire.ChannelID, peer route.Vertex) +} From 56c56060aa0a7ebc090bda2558107c3e3308faba Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Thu, 28 Aug 2025 13:40:00 +0200 Subject: [PATCH 2/8] lnd: add AuxChannelNegotiator to AuxComponents We now plug-in the aux channel negotiator to the server impl config. We also provide it to the peer config as that's where it's needed in order to inject custom records in the appropriate peer messages. --- config_builder.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config_builder.go b/config_builder.go index 6f9cb1f3e9..d3ca5e2b27 100644 --- a/config_builder.go +++ b/config_builder.go @@ -213,6 +213,11 @@ type AuxComponents struct { // AuxContractResolver is an optional interface that can be used to // modify the way contracts are resolved. AuxContractResolver fn.Option[lnwallet.AuxContractResolver] + + // AuxChannelNegotiator is an optional interface that allows aux channel + // implementations to inject and process custom records over channel + // related wire messages. + AuxChannelNegotiator fn.Option[lnwallet.AuxChannelNegotiator] } // DefaultWalletImpl is the default implementation of our normal, btcwallet From 7724d0fa6d8b892d4b2e7ae4261462f4442399ba Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Thu, 28 Aug 2025 13:44:37 +0200 Subject: [PATCH 3/8] lnwire: add custom records to init Before calling the new interface we first add the ability for the peer message itself to encode the new data records. --- lnwire/init_message.go | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/lnwire/init_message.go b/lnwire/init_message.go index b88891b087..dd3b471422 100644 --- a/lnwire/init_message.go +++ b/lnwire/init_message.go @@ -24,6 +24,10 @@ type Init struct { // Features field. Features *RawFeatureVector + // CustomRecords maps TLV types to byte slices, storing arbitrary data + // intended for inclusion in the ExtraData field of the init message. + CustomRecords CustomRecords + // ExtraData is the set of data that was appended to this message to // fill out the full maximum transport message size. These fields can // be used to specify optional data such as custom TLV fields. @@ -35,7 +39,6 @@ func NewInitMessage(gf *RawFeatureVector, f *RawFeatureVector) *Init { return &Init{ GlobalFeatures: gf, Features: f, - ExtraData: make([]byte, 0), } } @@ -52,11 +55,28 @@ var _ SizeableMessage = (*Init)(nil) // // This is part of the lnwire.Message interface. func (msg *Init) Decode(r io.Reader, pver uint32) error { - return ReadElements(r, + var msgExtraData ExtraOpaqueData + + err := ReadElements(r, &msg.GlobalFeatures, &msg.Features, - &msg.ExtraData, + &msgExtraData, ) + if err != nil { + return err + } + + customRecords, _, extraDData, err := ParseAndExtractCustomRecords( + msgExtraData, + ) + if err != nil { + return err + } + + msg.CustomRecords = customRecords + msg.ExtraData = extraDData + + return nil } // Encode serializes the target Init into the passed io.Writer observing @@ -72,7 +92,12 @@ func (msg *Init) Encode(w *bytes.Buffer, pver uint32) error { return err } - return WriteBytes(w, msg.ExtraData) + extraData, err := MergeAndEncode(nil, msg.ExtraData, msg.CustomRecords) + if err != nil { + return err + } + + return WriteBytes(w, extraData) } // MsgType returns the integer uniquely identifying this message type on the From 68bd35f7ad061ee40d9d8dadd54ebd9d00836127 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 23 Sep 2025 11:45:11 +0200 Subject: [PATCH 4/8] lnwire: update tests for init message --- lnwire/init_message_test.go | 61 +++++++++++++++++++++++++++++++++++++ lnwire/test_message.go | 4 +++ 2 files changed, 65 insertions(+) create mode 100644 lnwire/init_message_test.go diff --git a/lnwire/init_message_test.go b/lnwire/init_message_test.go new file mode 100644 index 0000000000..d0a3acaffb --- /dev/null +++ b/lnwire/init_message_test.go @@ -0,0 +1,61 @@ +package lnwire + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestInitEncodeDecode checks that we can encode and decode an Init message +// to and from a byte stream. +func TestInitEncodeDecode(t *testing.T) { + t.Parallel() + + // These are the raw bytes that we expect to be generated from the + // sample Init message. + rawBytes := []byte{ + // GlobalFeatures + 0x00, 0x01, 0xc0, + + // Features + 0x00, 0x01, 0xc0, + + // ExtraData - unknown odd-type TLV record. + 0x6f, // type (111) + 0x02, // length + 0x79, 0x79, // value + + // ExtraData - custom TLV record. + // TLV record for type 67676 + 0xfe, 0x00, 0x01, 0x08, 0x6c, // type (67676) + 0x05, // length + 0x01, 0x02, 0x03, 0x04, 0x05, // value + + // ExtraData - custom TLV record. + // TLV record for type 67777 + 0xfe, 0x00, 0x01, 0x08, 0xc1, // type (67777) + 0x03, // length + 0x01, 0x02, 0x03, // value + } + + // Create a new empty message and decode the raw bytes into it. + msg := &Init{} + r := bytes.NewReader(rawBytes) + err := msg.Decode(r, 0) + require.NoError(t, err) + + require.NotNil(t, msg.GlobalFeatures) + require.NotNil(t, msg.Features) + require.NotNil(t, msg.CustomRecords) + require.NotNil(t, msg.ExtraData) + + // Next, encode the message back into a new byte buffer. + var b bytes.Buffer + err = msg.Encode(&b, 0) + require.NoError(t, err) + + // The re-encoded bytes should be exactly the same as the original raw + // bytes. + require.Equal(t, rawBytes, b.Bytes()) +} diff --git a/lnwire/test_message.go b/lnwire/test_message.go index 8f946f190e..0c2fe5e55d 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -1188,6 +1188,10 @@ func (msg *Init) RandTestMessage(t *rapid.T) Message { local.Set(bit) } + ignoreRecords := fn.NewSet[uint64]() + + msg.ExtraData = RandExtraOpaqueData(t, ignoreRecords) + return NewInitMessage(global, local) } From 6dff1bd5ded44537dbc07a899ceb8c821c9977d9 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Thu, 28 Aug 2025 13:45:29 +0200 Subject: [PATCH 5/8] htlcswitch+peer: set and read aux custom records This is the final step, we actually call the interface and either provide or retrieve the custom features over the message. We also notify the aux components when channel reestablish is received. --- htlcswitch/link.go | 21 +++++++++++++++++++++ peer/brontide.go | 47 ++++++++++++++++++++++++++++++++++++++++++++-- server.go | 1 + 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 2d1dd7de01..4c81964cc2 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -298,6 +298,11 @@ type ChannelLinkConfig struct { // used to manage the bandwidth of the link. AuxTrafficShaper fn.Option[AuxTrafficShaper] + // AuxChannelNegotiator is an optional interface that allows aux channel + // implementations to inject and process custom records over channel + // related wire messages. + AuxChannelNegotiator fn.Option[lnwallet.AuxChannelNegotiator] + // QuiescenceTimeout is the max duration that the channel can be // quiesced. Any dependent protocols (dynamic commitments, splicing, // etc.) must finish their operations under this timeout value, @@ -987,6 +992,22 @@ func (l *channelLink) syncChanStates(ctx context.Context) error { // In any case, we'll then process their ChanSync message. l.log.Info("received re-establishment message from remote side") + // If we have an AuxChannelNegotiator we notify any external + // component for this message. This serves as a notification + // that the reestablish message was received. + l.cfg.AuxChannelNegotiator.WhenSome( + func(acn lnwallet.AuxChannelNegotiator) { + fundingPoint := l.channel.ChannelPoint() + cid := lnwire.NewChanIDFromOutPoint( + fundingPoint, + ) + + acn.ProcessReestablish( + cid, l.cfg.Peer.PubKey(), + ) + }, + ) + var ( openedCircuits []CircuitKey closedCircuits []CircuitKey diff --git a/peer/brontide.go b/peer/brontide.go index 57e340fb0e..1ea16d1e81 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -456,6 +456,11 @@ type Config struct { // used to modify the way the co-op close transaction is constructed. AuxChanCloser fn.Option[chancloser.AuxChanCloser] + // AuxChannelNegotiator is an optional interface that allows aux channel + // implementations to inject and process custom records over channel + // related wire messages. + AuxChannelNegotiator fn.Option[lnwallet.AuxChannelNegotiator] + // ShouldFwdExpEndorsement is a closure that indicates whether // experimental endorsement signals should be set. ShouldFwdExpEndorsement func() bool @@ -1454,8 +1459,9 @@ func (p *Brontide) addLink(chanPoint *wire.OutPoint, ShouldFwdExpEndorsement: p.cfg.ShouldFwdExpEndorsement, DisallowQuiescence: p.cfg.DisallowQuiescence || !p.remoteFeatures.HasFeature(lnwire.QuiescenceOptional), - AuxTrafficShaper: p.cfg.AuxTrafficShaper, - QuiescenceTimeout: p.cfg.QuiescenceTimeout, + AuxTrafficShaper: p.cfg.AuxTrafficShaper, + AuxChannelNegotiator: p.cfg.AuxChannelNegotiator, + QuiescenceTimeout: p.cfg.QuiescenceTimeout, } // Before adding our new link, purge the switch of any pending or live @@ -4537,6 +4543,19 @@ func (p *Brontide) handleInitMsg(msg *lnwire.Init) error { return fmt.Errorf("data loss protection required") } + // If we have an AuxChannelNegotiator and the peer sent aux features, + // process them. + p.cfg.AuxChannelNegotiator.WhenSome( + func(acn lnwallet.AuxChannelNegotiator) { + err = acn.ProcessInitRecords( + p.cfg.PubKeyBytes, msg.CustomRecords.Copy(), + ) + }, + ) + if err != nil { + return fmt.Errorf("could not process init records: %w", err) + } + return nil } @@ -4597,6 +4616,30 @@ func (p *Brontide) sendInitMsg(legacyChan bool) error { features.RawFeatureVector, ) + var err error + + // If we have an AuxChannelNegotiator, get custom feature bits to + // include in the init message. + p.cfg.AuxChannelNegotiator.WhenSome( + func(negotiator lnwallet.AuxChannelNegotiator) { + var auxRecords lnwire.CustomRecords + auxRecords, err = negotiator.GetInitRecords( + p.cfg.PubKeyBytes, + ) + if err != nil { + p.log.Warnf("Failed to get aux init features: "+ + "%v", err) + return + } + + mergedRecs := msg.CustomRecords.MergedCopy(auxRecords) + msg.CustomRecords = mergedRecs + }, + ) + if err != nil { + return err + } + return p.writeMessage(msg) } diff --git a/server.go b/server.go index 44be180ea9..33b8fd787a 100644 --- a/server.go +++ b/server.go @@ -4431,6 +4431,7 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, AuxChanCloser: s.implCfg.AuxChanCloser, AuxResolver: s.implCfg.AuxContractResolver, AuxTrafficShaper: s.implCfg.TrafficShaper, + AuxChannelNegotiator: s.implCfg.AuxChannelNegotiator, ShouldFwdExpEndorsement: func() bool { if s.cfg.ProtocolOptions.NoExperimentalEndorsement() { return false From be4134553f9ae60dfbd3703c87d80e1b3bcd232e Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 1 Sep 2025 16:38:49 +0200 Subject: [PATCH 6/8] lnwallet: include peer pub key in aux chan state In order to help external components to query the custom records of a channel we need to expose the remote peer pub key. We could look-up custom records based on the funding outpoint, but that relation is established when receiving the ChannelReady message. The external components may query the AuxChanState before that message is received, so let's make sure the peer pub key is also available. --- lnwallet/aux_leaf_store.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lnwallet/aux_leaf_store.go b/lnwallet/aux_leaf_store.go index aa2cc3b3e9..0a85050378 100644 --- a/lnwallet/aux_leaf_store.go +++ b/lnwallet/aux_leaf_store.go @@ -9,6 +9,7 @@ import ( "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/tlv" ) @@ -97,6 +98,10 @@ type AuxChanState struct { // funding output. TapscriptRoot fn.Option[chainhash.Hash] + // PeerPubKey is the peer pub key of the peer we've established this + // channel with. + PeerPubKey route.Vertex + // CustomBlob is an optional blob that can be used to store information // specific to a custom channel type. This information is only created // at channel funding time, and after wards is to be considered @@ -106,6 +111,8 @@ type AuxChanState struct { // NewAuxChanState creates a new AuxChanState from the given channel state. func NewAuxChanState(chanState *channeldb.OpenChannel) AuxChanState { + peerPub := chanState.IdentityPub.SerializeCompressed() + return AuxChanState{ ChanType: chanState.ChanType, FundingOutpoint: chanState.FundingOutpoint, @@ -116,6 +123,7 @@ func NewAuxChanState(chanState *channeldb.OpenChannel) AuxChanState { RemoteChanCfg: chanState.RemoteChanCfg, ThawHeight: chanState.ThawHeight, TapscriptRoot: chanState.TapscriptRoot, + PeerPubKey: route.Vertex(peerPub), CustomBlob: chanState.CustomBlob, } } From a58a52ee35a4231d5c0c7fc37b4b1647b7c36023 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 1 Sep 2025 16:41:13 +0200 Subject: [PATCH 7/8] funding: notify aux negotiator on ChannelReady We notify the aux channel negotiator that an established channel is now ready to use. --- funding/manager.go | 13 +++++++++++++ server.go | 1 + 2 files changed, 14 insertions(+) diff --git a/funding/manager.go b/funding/manager.go index 33d57328ea..4bfb7f9d09 100644 --- a/funding/manager.go +++ b/funding/manager.go @@ -568,6 +568,11 @@ type Config struct { // AuxResolver is an optional interface that can be used to modify the // way contracts are resolved. AuxResolver fn.Option[lnwallet.AuxContractResolver] + + // AuxChannelNegotiator is an optional interface that allows aux channel + // implementations to inject and process custom records over channel + // related wire messages. + AuxChannelNegotiator fn.Option[lnwallet.AuxChannelNegotiator] } // Manager acts as an orchestrator/bridge between the wallet's @@ -4019,6 +4024,14 @@ func (f *Manager) handleChannelReady(peer lnpeer.Peer, //nolint:funlen defer f.wg.Done() + // Notify the aux hook that the specified peer just established a + // channel with us, identified by the given channel ID. + f.cfg.AuxChannelNegotiator.WhenSome( + func(acn lnwallet.AuxChannelNegotiator) { + acn.ProcessChannelReady(msg.ChanID, peer.PubKey()) + }, + ) + // If we are in development mode, we'll wait for specified duration // before processing the channel ready message. if f.cfg.Dev != nil { diff --git a/server.go b/server.go index 33b8fd787a..ac3894012b 100644 --- a/server.go +++ b/server.go @@ -1628,6 +1628,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, AuxFundingController: implCfg.AuxFundingController, AuxSigner: implCfg.AuxSigner, AuxResolver: implCfg.AuxContractResolver, + AuxChannelNegotiator: implCfg.AuxChannelNegotiator, }) if err != nil { return nil, err From 2302debd6cdafc276f9da1f059264c9d7b691c63 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 22 Sep 2025 15:49:54 +0200 Subject: [PATCH 8/8] docs: add release note --- docs/release-notes/release-notes-0.20.0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md index 50653dcffd..f237d3bb91 100644 --- a/docs/release-notes/release-notes-0.20.0.md +++ b/docs/release-notes/release-notes-0.20.0.md @@ -76,6 +76,11 @@ a certain amount of msats. [allow](https://github.com/lightningnetwork/lnd/pull/10087) `conf_target=1`. Previously they required `conf_target >= 2`. +* A new AuxComponent was added named AuxChannelNegotiator. This component aids + with custom data communication for aux channels, by injecting and handling + data in channel related wire messages. See + [PR](https://github.com/lightningnetwork/lnd/pull/10182) for more info. + ## RPC Additions * When querying [`ForwardingEvents`](https://github.com/lightningnetwork/lnd/pull/9813) logs, the response now include the incoming and outgoing htlc indices of the payment