Repository: lightningnetwork/lnd
Pull request #9897 (2025-06-04 11:42:36)

multi: explicitly define InboundFees in ChannelUpdate and ChannelEdgePolicy
In this PR we add explicit fields for the InboundFee record on both the `lnwire.ChannelUpdate` message as well as on our `models.ChannelEdgePolicy` internal type. 

Previously, we would extract the inbound fee record from the extra TLV bytes at any call-site where we needed it. But now we add explicit fields for it which will also open the way for our SQL implementation of the graph store to have an explicit column for it in the `channel_policies` table. It will also mean that for our SQL impl, we dont need the extra round trip of fetching TLV data in order to get the inbound fee for an update. 

### PR structure
We start bottom up:
1) first, we move the extraction of inbound fee data from the extra bytes to our DB layer at deserialisation time. For any errors encountered, we just treat the edge as nil (see commit message for more details). 
2) At any call-site that obtains the edge policy from the DB, we can now just use the new explicit field instead of parsing the tlv data blob.
3) Then, we update the `lnwire.ChannelUpdate` type with the new TLV record
4) Any construction of a ChannelEdgePolicy type from an lnwire.ChannelUpdate type can now use the InboundFee field directly instead of parsing the tlv data blob. 

Once this PR is merged, we can tag the TLV package and remove the replace added here in the go.mod file


Part of https://github.com/lightningnetwork/lnd/issues/9795


Patch:
diff --git a/discovery/gossiper.go b/discovery/gossiper.go
index 0b59c8af9..d6855ae45 100644
--- a/discovery/gossiper.go
+++ b/discovery/gossiper.go
@@ -3257,6 +3257,7 @@ func (d *AuthenticatedGossiper) handleChanUpdate(ctx context.Context,
 		MaxHTLC:                   upd.HtlcMaximumMsat,
 		FeeBaseMSat:               lnwire.MilliSatoshi(upd.BaseFee),
 		FeeProportionalMillionths: lnwire.MilliSatoshi(upd.FeeRate),
+		InboundFee:                upd.InboundFee.ValOpt(),
 		ExtraOpaqueData:           upd.ExtraOpaqueData,
 	}
 
diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md
index 7bc9d6e8e..451537870 100644
--- a/docs/release-notes/release-notes-0.20.0.md
+++ b/docs/release-notes/release-notes-0.20.0.md
@@ -88,6 +88,11 @@ circuit. The indices are only available for forwarding events saved after v0.20.
 # Technical and Architectural Updates
 ## BOLT Spec Updates
 
+* Explicitly define the [inbound fee TLV 
+  record](https://github.com/lightningnetwork/lnd/pull/9897) on the 
+  `channel_update` message and handle it explicitly throughout the code base 
+  instead of extracting it from the TLV stream at various call-sites.
+
 ## Testing
 
 ## Database
@@ -99,4 +104,5 @@ circuit. The indices are only available for forwarding events saved after v0.20.
 # Contributors (Alphabetical Order)
 
 * Abdulkbk
-Pins
+* Elle Mouton
+* Pins
diff --git a/go.mod b/go.mod
index d5716a01d..f7f6b08ff 100644
--- a/go.mod
+++ b/go.mod
@@ -203,6 +203,9 @@ require (
 // store have been included in a tagged sqldb version.
 replace github.com/lightningnetwork/lnd/sqldb => ./sqldb
 
+// TODO(elle): replace once the updated tlv package has been tagged.
+replace github.com/lightningnetwork/lnd/tlv => ./tlv
+
 // This replace is for https://github.com/advisories/GHSA-25xm-hr59-7c27
 replace github.com/ulikunitz/xz => github.com/ulikunitz/xz v0.5.11
 
diff --git a/go.sum b/go.sum
index 40665b84a..5760d9bd9 100644
--- a/go.sum
+++ b/go.sum
@@ -377,8 +377,6 @@ github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQ
 github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4=
 github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM=
 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA=
-github.com/lightningnetwork/lnd/tlv v1.3.1 h1:o7CZg06y+rJZfUMAo0WzBLr0pgBWCzrt0f9gpujYUzk=
-github.com/lightningnetwork/lnd/tlv v1.3.1/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc=
 github.com/lightningnetwork/lnd/tor v1.1.6 h1:WHUumk7WgU6BUFsqHuqszI9P6nfhMeIG+rjJBlVE6OE=
 github.com/lightningnetwork/lnd/tor v1.1.6/go.mod h1:qSRB8llhAK+a6kaTPWOLLXSZc6Hg8ZC0mq1sUQ/8JfI=
 github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw=
diff --git a/graph/builder.go b/graph/builder.go
index 3350eeb33..99fc73c17 100644
--- a/graph/builder.go
+++ b/graph/builder.go
@@ -943,7 +943,7 @@ func (b *Builder) ApplyChannelUpdate(msg *lnwire.ChannelUpdate1) bool {
 		return false
 	}
 
-	err = b.UpdateEdge(&models.ChannelEdgePolicy{
+	update := &models.ChannelEdgePolicy{
 		SigBytes:                  msg.Signature.ToSignatureBytes(),
 		ChannelID:                 msg.ShortChannelID.ToUint64(),
 		LastUpdate:                time.Unix(int64(msg.Timestamp), 0),
@@ -954,8 +954,11 @@ func (b *Builder) ApplyChannelUpdate(msg *lnwire.ChannelUpdate1) bool {
 		MaxHTLC:                   msg.HtlcMaximumMsat,
 		FeeBaseMSat:               lnwire.MilliSatoshi(msg.BaseFee),
 		FeeProportionalMillionths: lnwire.MilliSatoshi(msg.FeeRate),
+		InboundFee:                msg.InboundFee.ValOpt(),
 		ExtraOpaqueData:           msg.ExtraOpaqueData,
-	})
+	}
+
+	err = b.UpdateEdge(update)
 	if err != nil && !IsError(err, ErrIgnored, ErrOutdated) {
 		log.Errorf("Unable to apply channel update: %v", err)
 		return false
diff --git a/graph/db/errors.go b/graph/db/errors.go
index afdbdb457..8201980aa 100644
--- a/graph/db/errors.go
+++ b/graph/db/errors.go
@@ -12,6 +12,10 @@ var (
 	ErrEdgePolicyOptionalFieldNotFound = fmt.Errorf("optional field not " +
 		"present")
 
+	// ErrParsingExtraTLVBytes is returned when we attempt to parse
+	// extra opaque bytes as a TLV stream, but the parsing fails.
+	ErrParsingExtraTLVBytes = fmt.Errorf("error parsing extra TLV bytes")
+
 	// ErrGraphNotFound is returned when at least one of the components of
 	// graph doesn't exist.
 	ErrGraphNotFound = fmt.Errorf("graph bucket not initialized")
diff --git a/graph/db/graph_cache.go b/graph/db/graph_cache.go
index cefa88a85..141f610b2 100644
--- a/graph/db/graph_cache.go
+++ b/graph/db/graph_cache.go
@@ -180,17 +180,6 @@ func (c *GraphCache) updateOrAddEdge(node route.Vertex, edge *DirectedChannel) {
 func (c *GraphCache) UpdatePolicy(policy *models.ChannelEdgePolicy, fromNode,
 	toNode route.Vertex, edge1 bool) {
 
-	// Extract inbound fee if possible and available. If there is a decoding
-	// error, ignore this policy.
-	var inboundFee lnwire.Fee
-	_, err := policy.ExtraOpaqueData.ExtractRecords(&inboundFee)
-	if err != nil {
-		log.Errorf("Failed to extract records from edge policy %v: %v",
-			policy.ChannelID, err)
-
-		return
-	}
-
 	c.mtx.Lock()
 	defer c.mtx.Unlock()
 
@@ -216,13 +205,17 @@ func (c *GraphCache) UpdatePolicy(policy *models.ChannelEdgePolicy, fromNode,
 		// policy for node 1.
 		case channel.IsNode1 && edge1:
 			channel.OutPolicySet = true
-			channel.InboundFee = inboundFee
+			policy.InboundFee.WhenSome(func(fee lnwire.Fee) {
+				channel.InboundFee = fee
+			})
 
 		// This is node 2, and it is edge 2, so this is the outgoing
 		// policy for node 2.
 		case !channel.IsNode1 && !edge1:
 			channel.OutPolicySet = true
-			channel.InboundFee = inboundFee
+			policy.InboundFee.WhenSome(func(fee lnwire.Fee) {
+				channel.InboundFee = fee
+			})
 
 		// The other two cases left mean it's the inbound policy for the
 		// node.
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index b08c86022..51a1d8483 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -22,6 +22,7 @@ import (
 	"github.com/btcsuite/btcwallet/walletdb"
 	"github.com/lightningnetwork/lnd/aliasmgr"
 	"github.com/lightningnetwork/lnd/batch"
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/graph/db/models"
 	"github.com/lightningnetwork/lnd/input"
 	"github.com/lightningnetwork/lnd/kvdb"
@@ -284,6 +285,13 @@ func (c *KVStore) getChannelMap(edges kvdb.RBucket) (
 		case errors.Is(err, ErrEdgePolicyOptionalFieldNotFound):
 			return nil
 
+		// We don't want a single policy with bad TLV data to stop us
+		// from loading the rest of the data, so we just skip this
+		// policy. This is for backwards compatibility since we did not
+		// use to validate TLV data in the past before persisting it.
+		case errors.Is(err, ErrParsingExtraTLVBytes):
+			return nil
+
 		case err != nil:
 			return err
 		}
@@ -474,16 +482,6 @@ func (c *KVStore) forEachNodeDirectedChannel(tx kvdb.RTx,
 			cachedInPolicy.ToNodeFeatures = toNodeFeatures
 		}
 
-		var inboundFee lnwire.Fee
-		if p1 != nil {
-			// Extract inbound fee. If there is a decoding error,
-			// skip this edge.
-			_, err := p1.ExtraOpaqueData.ExtractRecords(&inboundFee)
-			if err != nil {
-				return nil
-			}
-		}
-
 		directedChannel := &DirectedChannel{
 			ChannelID:    e.ChannelID,
 			IsNode1:      node == e.NodeKey1Bytes,
@@ -491,7 +489,12 @@ func (c *KVStore) forEachNodeDirectedChannel(tx kvdb.RTx,
 			Capacity:     e.Capacity,
 			OutPolicySet: p1 != nil,
 			InPolicy:     cachedInPolicy,
-			InboundFee:   inboundFee,
+		}
+
+		if p1 != nil {
+			p1.InboundFee.WhenSome(func(fee lnwire.Fee) {
+				directedChannel.InboundFee = fee
+			})
 		}
 
 		if node == e.NodeKey2Bytes {
@@ -2342,7 +2345,7 @@ func (c *KVStore) FilterChannelRange(startHeight,
 				edge, err := deserializeChanEdgePolicyRaw(r)
 				if err != nil && !errors.Is(
 					err, ErrEdgePolicyOptionalFieldNotFound,
-				) {
+				) && !errors.Is(err, ErrParsingExtraTLVBytes) {
 
 					return err
 				}
@@ -2357,7 +2360,7 @@ func (c *KVStore) FilterChannelRange(startHeight,
 				edge, err := deserializeChanEdgePolicyRaw(r)
 				if err != nil && !errors.Is(
 					err, ErrEdgePolicyOptionalFieldNotFound,
-				) {
+				) && !errors.Is(err, ErrParsingExtraTLVBytes) {
 
 					return err
 				}
@@ -4334,15 +4337,20 @@ func putChanEdgePolicy(edges kvdb.RwBucket, edge *models.ChannelEdgePolicy,
 		// need to deserialize the existing policy within the database
 		// (now outdated by the new one), and delete its corresponding
 		// entry within the update index. We'll ignore any
-		// ErrEdgePolicyOptionalFieldNotFound error, as we only need
-		// the channel ID and update time to delete the entry.
+		// ErrEdgePolicyOptionalFieldNotFound or ErrParsingExtraTLVBytes
+		// errors, as we only need the channel ID and update time to
+		// delete the entry.
+		//
 		// TODO(halseth): get rid of these invalid policies in a
 		// migration.
+		// TODO(elle): complete the above TODO in migration from kvdb
+		// to SQL.
 		oldEdgePolicy, err := deserializeChanEdgePolicy(
 			bytes.NewReader(edgeBytes),
 		)
 		if err != nil &&
-			!errors.Is(err, ErrEdgePolicyOptionalFieldNotFound) {
+			!errors.Is(err, ErrEdgePolicyOptionalFieldNotFound) &&
+			!errors.Is(err, ErrParsingExtraTLVBytes) {
 
 			return err
 		}
@@ -4449,6 +4457,11 @@ func fetchChanEdgePolicy(edges kvdb.RBucket, chanID []byte,
 	case errors.Is(err, ErrEdgePolicyOptionalFieldNotFound):
 		return nil, nil
 
+	// If the policy contains invalid TLV bytes, we return nil as if
+	// the policy was unknown.
+	case errors.Is(err, ErrParsingExtraTLVBytes):
+		return nil, nil
+
 	case err != nil:
 		return nil, err
 	}
@@ -4546,6 +4559,12 @@ func serializeChanEdgePolicy(w io.Writer, edge *models.ChannelEdgePolicy,
 		}
 	}
 
+	// Validate that the ExtraOpaqueData is in fact a valid TLV stream.
+	err = edge.ExtraOpaqueData.ValidateTLV()
+	if err != nil {
+		return fmt.Errorf("%w: %w", ErrParsingExtraTLVBytes, err)
+	}
+
 	if len(edge.ExtraOpaqueData) > MaxAllowedExtraOpaqueBytes {
 		return ErrTooManyExtraOpaqueBytes(len(edge.ExtraOpaqueData))
 	}
@@ -4562,15 +4581,18 @@ func serializeChanEdgePolicy(w io.Writer, edge *models.ChannelEdgePolicy,
 
 func deserializeChanEdgePolicy(r io.Reader) (*models.ChannelEdgePolicy, error) {
 	// Deserialize the policy. Note that in case an optional field is not
-	// found, both an error and a populated policy object are returned.
-	edge, deserializeErr := deserializeChanEdgePolicyRaw(r)
-	if deserializeErr != nil &&
-		!errors.Is(deserializeErr, ErrEdgePolicyOptionalFieldNotFound) {
+	// found or if the edge has invalid TLV data, then both an error and a
+	// populated policy object are returned so that the caller can decide
+	// if it still wants to use the edge or not.
+	edge, err := deserializeChanEdgePolicyRaw(r)
+	if err != nil &&
+		!errors.Is(err, ErrEdgePolicyOptionalFieldNotFound) &&
+		!errors.Is(err, ErrParsingExtraTLVBytes) {
 
-		return nil, deserializeErr
+		return nil, err
 	}
 
-	return edge, deserializeErr
+	return edge, err
 }
 
 func deserializeChanEdgePolicyRaw(r io.Reader) (*models.ChannelEdgePolicy,
@@ -4657,6 +4679,22 @@ func deserializeChanEdgePolicyRaw(r io.Reader) (*models.ChannelEdgePolicy,
 		edge.ExtraOpaqueData = opq[8:]
 	}
 
+	// Attempt to extract the inbound fee from the opaque data. If we fail
+	// to parse the TLV here, we return an error we also return the edge
+	// so that the caller can still use it. This is for backwards
+	// compatibility in case we have already persisted some policies that
+	// have invalid TLV data.
+	var inboundFee lnwire.Fee
+	typeMap, err := edge.ExtraOpaqueData.ExtractRecords(&inboundFee)
+	if err != nil {
+		return edge, fmt.Errorf("%w: %w", ErrParsingExtraTLVBytes, err)
+	}
+
+	val, ok := typeMap[lnwire.FeeRecordType]
+	if ok && val == nil {
+		edge.InboundFee = fn.Some(inboundFee)
+	}
+
 	return edge, nil
 }
 
diff --git a/graph/db/models/channel_edge_policy.go b/graph/db/models/channel_edge_policy.go
index 365acbfe1..48d748ee0 100644
--- a/graph/db/models/channel_edge_policy.go
+++ b/graph/db/models/channel_edge_policy.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/btcsuite/btcd/btcec/v2/ecdsa"
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/lnwire"
 )
 
@@ -65,6 +66,15 @@ type ChannelEdgePolicy struct {
 	// to. Using this pub key, the channel graph can further be traversed.
 	ToNode [33]byte
 
+	// InboundFee is the fee that must be paid for incoming HTLCs.
+	//
+	// NOTE: for our kvdb implementation of the graph store, inbound fees
+	// are still only persisted as part of extra opaque data and so this
+	// field is not explicitly stored but is rather populated from the
+	// ExtraOpaqueData field on deserialization. For our SQL implementation,
+	// this field will be explicitly persisted in the database.
+	InboundFee fn.Option[lnwire.Fee]
+
 	// ExtraOpaqueData is the set of data that was appended to this
 	// message, some of which we may not actually know how to iterate or
 	// parse. By holding onto this data, we ensure that we're able to
diff --git a/graph/db/notifications.go b/graph/db/notifications.go
index d573e0aa5..6c0acba88 100644
--- a/graph/db/notifications.go
+++ b/graph/db/notifications.go
@@ -12,6 +12,7 @@ import (
 	"github.com/btcsuite/btcd/btcutil"
 	"github.com/btcsuite/btcd/wire"
 	"github.com/go-errors/errors"
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/graph/db/models"
 	"github.com/lightningnetwork/lnd/lnutils"
 	"github.com/lightningnetwork/lnd/lnwire"
@@ -360,6 +361,9 @@ type ChannelEdgeUpdate struct {
 	// payments.
 	Disabled bool
 
+	// InboundFee is the fee that must be paid for incoming HTLCs.
+	InboundFee fn.Option[lnwire.Fee]
+
 	// ExtraOpaqueData 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.
@@ -442,6 +446,7 @@ func (c *ChannelGraph) addToTopologyChange(update *TopologyChange,
 			AdvertisingNode: aNode,
 			ConnectingNode:  cNode,
 			Disabled:        m.ChannelFlags&lnwire.ChanUpdateDisabled != 0,
+			InboundFee:      m.InboundFee,
 			ExtraOpaqueData: m.ExtraOpaqueData,
 		}
 
diff --git a/lnwire/channel_update.go b/lnwire/channel_update.go
index 55d9d3181..558d81a9e 100644
--- a/lnwire/channel_update.go
+++ b/lnwire/channel_update.go
@@ -6,6 +6,7 @@ import (
 	"io"
 
 	"github.com/btcsuite/btcd/chaincfg/chainhash"
+	"github.com/lightningnetwork/lnd/tlv"
 )
 
 // ChanUpdateMsgFlags is a bitfield that signals whether optional fields are
@@ -114,6 +115,10 @@ type ChannelUpdate1 struct {
 	// HtlcMaximumMsat is the maximum HTLC value which will be accepted.
 	HtlcMaximumMsat MilliSatoshi
 
+	// InboundFee is an optional TLV record that contains the fee
+	// information for incoming HTLCs.
+	InboundFee tlv.OptionalRecordT[tlv.TlvType55555, Fee]
+
 	// 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.
@@ -156,12 +161,27 @@ func (a *ChannelUpdate1) Decode(r io.Reader, _ uint32) error {
 		}
 	}
 
-	err = a.ExtraOpaqueData.Decode(r)
+	var tlvRecords ExtraOpaqueData
+	if err := ReadElements(r, &tlvRecords); err != nil {
+		return err
+	}
+
+	var inboundFee = a.InboundFee.Zero()
+	typeMap, err := tlvRecords.ExtractRecords(&inboundFee)
 	if err != nil {
 		return err
 	}
 
-	return a.ExtraOpaqueData.ValidateTLV()
+	val, ok := typeMap[a.InboundFee.TlvType()]
+	if ok && val == nil {
+		a.InboundFee = tlv.SomeRecordT(inboundFee)
+	}
+
+	if len(tlvRecords) != 0 {
+		a.ExtraOpaqueData = tlvRecords
+	}
+
+	return nil
 }
 
 // Encode serializes the target ChannelUpdate into the passed io.Writer
@@ -218,6 +238,16 @@ func (a *ChannelUpdate1) Encode(w *bytes.Buffer, pver uint32) error {
 		}
 	}
 
+	recordProducers := make([]tlv.RecordProducer, 0, 1)
+	a.InboundFee.WhenSome(func(fee tlv.RecordT[tlv.TlvType55555, Fee]) {
+		recordProducers = append(recordProducers, &fee)
+	})
+
+	err := EncodeMessageExtraData(&a.ExtraOpaqueData, recordProducers...)
+	if err != nil {
+		return err
+	}
+
 	// Finally, append any extra opaque data.
 	return WriteBytes(w, a.ExtraOpaqueData)
 }
diff --git a/netann/channel_update.go b/netann/channel_update.go
index efc5cf61e..1bb509b08 100644
--- a/netann/channel_update.go
+++ b/netann/channel_update.go
@@ -13,6 +13,7 @@ import (
 	"github.com/lightningnetwork/lnd/keychain"
 	"github.com/lightningnetwork/lnd/lnwallet"
 	"github.com/lightningnetwork/lnd/lnwire"
+	"github.com/lightningnetwork/lnd/tlv"
 	"github.com/pkg/errors"
 )
 
@@ -138,7 +139,7 @@ func ExtractChannelUpdate(ownerPubKey []byte,
 func UnsignedChannelUpdateFromEdge(info *models.ChannelEdgeInfo,
 	policy *models.ChannelEdgePolicy) *lnwire.ChannelUpdate1 {
 
-	return &lnwire.ChannelUpdate1{
+	update := &lnwire.ChannelUpdate1{
 		ChainHash:       info.ChainHash,
 		ShortChannelID:  lnwire.NewShortChanIDFromInt(policy.ChannelID),
 		Timestamp:       uint32(policy.LastUpdate.Unix()),
@@ -151,6 +152,13 @@ func UnsignedChannelUpdateFromEdge(info *models.ChannelEdgeInfo,
 		FeeRate:         uint32(policy.FeeProportionalMillionths),
 		ExtraOpaqueData: policy.ExtraOpaqueData,
 	}
+	policy.InboundFee.WhenSome(func(fee lnwire.Fee) {
+		update.InboundFee = tlv.SomeRecordT(
+			tlv.NewRecordT[tlv.TlvType55555, lnwire.Fee](fee),
+		)
+	})
+
+	return update
 }
 
 // ChannelUpdateFromEdge reconstructs a signed ChannelUpdate from the given edge
diff --git a/peer/brontide.go b/peer/brontide.go
index 26d02d3e7..5d00e14ab 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -1205,27 +1205,17 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) (
 		// routing policy into a forwarding policy.
 		var forwardingPolicy *models.ForwardingPolicy
 		if selfPolicy != nil {
-			var inboundWireFee lnwire.Fee
-			_, err := selfPolicy.ExtraOpaqueData.ExtractRecords(
-				&inboundWireFee,
-			)
-			if err != nil {
-				return nil, err
-			}
-
-			inboundFee := models.NewInboundFeeFromWire(
-				inboundWireFee,
-			)
-
 			forwardingPolicy = &models.ForwardingPolicy{
 				MinHTLCOut:    selfPolicy.MinHTLC,
 				MaxHTLC:       selfPolicy.MaxHTLC,
 				BaseFee:       selfPolicy.FeeBaseMSat,
 				FeeRate:       selfPolicy.FeeProportionalMillionths,
 				TimeLockDelta: uint32(selfPolicy.TimeLockDelta),
-
-				InboundFee: inboundFee,
 			}
+			selfPolicy.InboundFee.WhenSome(func(fee lnwire.Fee) {
+				inboundFee := models.NewInboundFeeFromWire(fee)
+				forwardingPolicy.InboundFee = inboundFee
+			})
 		} else {
 			p.log.Warnf("Unable to find our forwarding policy "+
 				"for channel %v, using default values",
diff --git a/routing/localchans/manager.go b/routing/localchans/manager.go
index 3beb28525..492f8f18c 100644
--- a/routing/localchans/manager.go
+++ b/routing/localchans/manager.go
@@ -127,12 +127,10 @@ func (r *Manager) UpdatePolicy(newSchema routing.ChannelPolicy,
 			Edge: edge,
 		})
 
-		// Extract inbound fees from the ExtraOpaqueData.
 		var inboundWireFee lnwire.Fee
-		_, err = edge.ExtraOpaqueData.ExtractRecords(&inboundWireFee)
-		if err != nil {
-			return err
-		}
+		edge.InboundFee.WhenSome(func(fee lnwire.Fee) {
+			inboundWireFee = fee
+		})
 		inboundFee := models.NewInboundFeeFromWire(inboundWireFee)
 
 		// Add updated policy to list of policies to send to switch.
@@ -372,6 +370,8 @@ func (r *Manager) updateEdge(chanPoint wire.OutPoint,
 	err = fn.MapOptionZ(newSchema.InboundFee,
 		func(f models.InboundFee) error {
 			inboundWireFee := f.ToWire()
+			edge.InboundFee = fn.Some(inboundWireFee)
+
 			return edge.ExtraOpaqueData.PackRecords(
 				&inboundWireFee,
 			)
diff --git a/rpcserver.go b/rpcserver.go
index 79d91e646..758400b1f 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -6776,23 +6776,6 @@ func marshalExtraOpaqueData(data []byte) map[uint64][]byte {
 	return records
 }
 
-// extractInboundFeeSafe tries to extract the inbound fee from the given extra
-// opaque data tlv block. If parsing fails, a zero inbound fee is returned. This
-// function is typically used on unvalidated data coming stored in the database.
-// There is not much we can do other than ignoring errors here.
-func extractInboundFeeSafe(data lnwire.ExtraOpaqueData) lnwire.Fee {
-	var inboundFee lnwire.Fee
-
-	_, err := data.ExtractRecords(&inboundFee)
-	if err != nil {
-		// Return zero fee. Do not return the inboundFee variable
-		// because it may be undefined.
-		return lnwire.Fee{}
-	}
-
-	return inboundFee
-}
-
 func marshalDBEdge(edgeInfo *models.ChannelEdgeInfo,
 	c1, c2 *models.ChannelEdgePolicy) *lnrpc.ChannelEdge {
 
@@ -6842,7 +6825,7 @@ func marshalDBRoutingPolicy(
 	disabled := policy.ChannelFlags&lnwire.ChanUpdateDisabled != 0
 
 	customRecords := marshalExtraOpaqueData(policy.ExtraOpaqueData)
-	inboundFee := extractInboundFeeSafe(policy.ExtraOpaqueData)
+	inboundFee := policy.InboundFee.UnwrapOr(lnwire.Fee{})
 
 	return &lnrpc.RoutingPolicy{
 		TimeLockDelta:    uint32(policy.TimeLockDelta),
@@ -7349,9 +7332,7 @@ func marshallTopologyChange(
 		customRecords := marshalExtraOpaqueData(
 			channelUpdate.ExtraOpaqueData,
 		)
-		inboundFee := extractInboundFeeSafe(
-			channelUpdate.ExtraOpaqueData,
-		)
+		inboundFee := channelUpdate.InboundFee.UnwrapOr(lnwire.Fee{})
 
 		channelUpdates[i] = &lnrpc.ChannelEdgeUpdate{
 			ChanId: channelUpdate.ChanID,
@@ -7698,14 +7679,9 @@ func (r *rpcServer) FeeReport(ctx context.Context,
 				edgePolicy.FeeProportionalMillionths
 			feeRate := float64(feeRateFixedPoint) / feeBase
 
-			// Decode inbound fee from extra data.
-			var inboundFee lnwire.Fee
-			_, err := edgePolicy.ExtraOpaqueData.ExtractRecords(
-				&inboundFee,
+			inboundFee := edgePolicy.InboundFee.UnwrapOr(
+				lnwire.Fee{},
 			)
-			if err != nil {
-				return err
-			}
 
 			// TODO(roasbeef): also add stats for revenue for each
 			// channel
diff --git a/tlv/internal/gen/gen_tlv_types.go b/tlv/internal/gen/gen_tlv_types.go
index ab9bbbbd6..9f15e3b69 100644
--- a/tlv/internal/gen/gen_tlv_types.go
+++ b/tlv/internal/gen/gen_tlv_types.go
@@ -22,6 +22,10 @@ const (
 	// second defined unsigned TLV range used in pure TLV messages.
 	pureTLVSecondUnsignedTypeRangeStart uint32 = 3000000000
 
+	// inboundFeeType defines the TLV type used within the channel_update
+	// message to indicate the inbound fee for a channel.
+	inboundFeeType = 55555
+
 	defaultOutputFile = "tlv_types_generated.go"
 )
 
@@ -29,6 +33,7 @@ const (
 var typeMarkers = map[uint32]struct{}{
 	pureTLVSecondSignedTypeRangeStart:   {},
 	pureTLVSecondUnsignedTypeRangeStart: {},
+	inboundFeeType:                      {},
 }
 
 const typeCodeTemplate = `// Code generated by tlv/internal/gen; DO NOT EDIT.
diff --git a/tlv/tlv_types_generated.go b/tlv/tlv_types_generated.go
index 9a80f9364..f0eefc4a5 100644
--- a/tlv/tlv_types_generated.go
+++ b/tlv/tlv_types_generated.go
@@ -3012,6 +3012,16 @@ func (t *tlvType300) tlv() {}
 
 type TlvType300 = *tlvType300
 
+type tlvType55555 struct{}
+
+func (t *tlvType55555) TypeVal() Type {
+	return 55555
+}
+
+func (t *tlvType55555) tlv() {}
+
+type TlvType55555 = *tlvType55555
+
 type tlvType65536 struct{}
 
 func (t *tlvType65536) TypeVal() Type {


Test patch:
diff --git a/graph/db/graph_cache_test.go b/graph/db/graph_cache_test.go
index 087fb81ac..96318366d 100644
--- a/graph/db/graph_cache_test.go
+++ b/graph/db/graph_cache_test.go
@@ -4,6 +4,7 @@ import (
 	"encoding/hex"
 	"testing"
 
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/graph/db/models"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
@@ -37,11 +38,17 @@ func TestGraphCacheAddNode(t *testing.T) {
 			channelFlagA, channelFlagB = 1, 0
 		}
 
+		inboundFee := lnwire.Fee{
+			BaseFee: 10,
+			FeeRate: 20,
+		}
+
 		outPolicy1 := &models.ChannelEdgePolicy{
 			ChannelID:    1000,
 			ChannelFlags: lnwire.ChanUpdateChanFlags(channelFlagA),
 			ToNode:       nodeB,
 			// Define an inbound fee.
+			InboundFee: fn.Some(inboundFee),
 			ExtraOpaqueData: []byte{
 				253, 217, 3, 8, 0, 0, 0, 10, 0, 0, 0, 20,
 			},
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index c8a5c54b2..efe1b4642 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -4195,19 +4195,25 @@ func TestGraphCacheForEachNodeChannel(t *testing.T) {
 
 	directedChan := getSingleChannel()
 	require.NotNil(t, directedChan)
-	require.Equal(t, directedChan.InboundFee, lnwire.Fee{
+	expectedInbound := lnwire.Fee{
 		BaseFee: 10,
 		FeeRate: 20,
-	})
+	}
+	require.Equal(t, expectedInbound, directedChan.InboundFee)
 
-	// Set an invalid inbound fee and check that the edge is no longer
-	// returned.
+	// Set an invalid inbound fee and check that persistence fails.
 	edge1.ExtraOpaqueData = []byte{
 		253, 217, 3, 8, 0,
 	}
-	require.NoError(t, graph.UpdateEdgePolicy(edge1))
+	require.ErrorIs(
+		t, graph.UpdateEdgePolicy(edge1), ErrParsingExtraTLVBytes,
+	)
 
-	require.Nil(t, getSingleChannel())
+	// Since persistence of the last update failed, we should still bet
+	// the previous result when we query the channel again.
+	directedChan = getSingleChannel()
+	require.NotNil(t, directedChan)
+	require.Equal(t, expectedInbound, directedChan.InboundFee)
 }
 
 // TestGraphLoading asserts that the cache is properly reconstructed after a
diff --git a/graph/notifications_test.go b/graph/notifications_test.go
index 71c6f4a34..2f2ec1b4b 100644
--- a/graph/notifications_test.go
+++ b/graph/notifications_test.go
@@ -121,6 +121,7 @@ func randEdgePolicy(chanID *lnwire.ShortChannelID,
 		FeeBaseMSat:               lnwire.MilliSatoshi(prand.Int31()),
 		FeeProportionalMillionths: lnwire.MilliSatoshi(prand.Int31()),
 		ToNode:                    node.PubKeyBytes,
+		InboundFee:                fn.Some(inboundFee),
 		ExtraOpaqueData:           extraOpaqueData,
 	}, nil
 }
diff --git a/lnwire/test_message.go b/lnwire/test_message.go
index 8b3d98400..9d086c868 100644
--- a/lnwire/test_message.go
+++ b/lnwire/test_message.go
@@ -12,6 +12,7 @@ import (
 	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
 	"github.com/lightningnetwork/lnd/tlv"
+	"github.com/stretchr/testify/require"
 	"pgregory.net/rapid"
 )
 
@@ -406,6 +407,45 @@ func (a *ChannelUpdate1) RandTestMessage(t *rapid.T) Message {
 		maxHtlc = 0
 	}
 
+	// Randomly decide if an inbound fee should be included.
+	// By default, our extra opaque data will just be random TLV but if we
+	// include an inbound fee, then we will also set the record in the
+	// extra opaque data.
+	var (
+		customRecords, _ = RandCustomRecords(t, nil, false)
+		inboundFee       tlv.OptionalRecordT[tlv.TlvType55555, Fee]
+	)
+	includeInboundFee := rapid.Bool().Draw(t, "includeInboundFee")
+	if includeInboundFee {
+		if customRecords == nil {
+			customRecords = make(CustomRecords)
+		}
+
+		inFeeBase := int32(
+			rapid.IntRange(-1000, 1000).Draw(t, "inFeeBase"),
+		)
+		inFeeProp := int32(
+			rapid.IntRange(-1000, 1000).Draw(t, "inFeeProp"),
+		)
+		fee := Fee{
+			BaseFee: inFeeBase,
+			FeeRate: inFeeProp,
+		}
+		inboundFee = tlv.SomeRecordT(
+			tlv.NewRecordT[tlv.TlvType55555, Fee](fee),
+		)
+
+		var b bytes.Buffer
+		feeRecord := fee.Record()
+		err := feeRecord.Encode(&b)
+		require.NoError(t, err)
+
+		customRecords[uint64(FeeRecordType)] = b.Bytes()
+	}
+
+	extraBytes, err := customRecords.Serialize()
+	require.NoError(t, err)
+
 	return &ChannelUpdate1{
 		Signature:      RandSignature(t),
 		ChainHash:      hash,
@@ -428,7 +468,8 @@ func (a *ChannelUpdate1) RandTestMessage(t *rapid.T) Message {
 			t, "feeRate"),
 		),
 		HtlcMaximumMsat: maxHtlc,
-		ExtraOpaqueData: RandExtraOpaqueData(t, nil),
+		InboundFee:      inboundFee,
+		ExtraOpaqueData: extraBytes,
 	}
 }
 
diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go
index bc3a9ef58..361fb83d4 100644
--- a/routing/pathfind_test.go
+++ b/routing/pathfind_test.go
@@ -666,15 +666,25 @@ func createTestGraphFromChannels(t *testing.T, useCache bool,
 			return nil, err
 		}
 
-		getExtraData := func(
-			end *testChannelEnd) lnwire.ExtraOpaqueData {
+		getInboundFees := func(
+			end *testChannelEnd) fn.Option[lnwire.Fee] {
 
-			var extraData lnwire.ExtraOpaqueData
 			inboundFee := lnwire.Fee{
 				BaseFee: int32(end.InboundFeeBaseMsat),
 				FeeRate: int32(end.InboundFeeRate),
 			}
-			require.NoError(t, extraData.PackRecords(&inboundFee))
+
+			return fn.Some(inboundFee)
+		}
+		getExtraData := func(
+			end *testChannelEnd) lnwire.ExtraOpaqueData {
+
+			var extraData lnwire.ExtraOpaqueData
+
+			inboundFee := getInboundFees(end)
+			inboundFee.WhenSome(func(fee lnwire.Fee) {
+				require.NoError(t, extraData.PackRecords(&fee))
+			})
 
 			return extraData
 		}
@@ -701,6 +711,7 @@ func createTestGraphFromChannels(t *testing.T, useCache bool,
 				FeeBaseMSat:               node1.FeeBaseMsat,
 				FeeProportionalMillionths: node1.FeeRate,
 				ToNode:                    node2Vertex,
+				InboundFee:                getInboundFees(node1), //nolint:ll
 				ExtraOpaqueData:           getExtraData(node1),
 			}
 			if err := graph.UpdateEdgePolicy(edgePolicy); err != nil {
@@ -731,6 +742,7 @@ func createTestGraphFromChannels(t *testing.T, useCache bool,
 				FeeBaseMSat:               node2.FeeBaseMsat,
 				FeeProportionalMillionths: node2.FeeRate,
 				ToNode:                    node1Vertex,
+				InboundFee:                getInboundFees(node2), //nolint:ll
 				ExtraOpaqueData:           getExtraData(node2),
 			}
 			if err := graph.UpdateEdgePolicy(edgePolicy); err != nil {