What changed, and why it matters
This commit fixes a bug in how Eclair handles 'liquidity ads' data embedded in Lightning node announcements. Previously, if a peer broadcast a node_announcement containing a liquidity-ads field with empty funding rates or empty payment types, the node could not re-encode the message correctly, which could cause it to relay or store invalid gossip. The patch now rejects such malformed announcements immediately. It also improves codec performance by storing the raw payment-type bitfield instead of eagerly decoding unknown payment types.
Treat as a hardening/bugfix patch. Operators should upgrade to avoid propagating malformed liquidity-ads gossip. Review whether the InvalidSignature gossip decision is the most appropriate error type for empty liquidity-ads fields, since the failure is not actually a signature validation issue.
Security signals we found
Malformed gossip TLV re-encoding failure mitigated by explicit rejection
Empty fundingRates/encodedPaymentTypes in node_announcement now treated as invalid
Codec change reduces attack surface from unknown payment-type bit manipulation
No explicit CVE, advisory, or security disclosure referenced in commit
Evidence from the diff
The change refactors LiquidityAds.WillFundRates to keep encodedPaymentTypes as a raw ByteVector and derive the Set[PaymentType] lazily, ignoring unknown bits. A new WillFundRates companion object handles bitfield encoding/decoding. In Validation.scala, node_announcements whose optional liquidity-ads TLV has empty fundingRates or empty encodedPaymentTypes are now dropped with an InvalidSignature gossip decision. Tests are updated to use the new factory method and to verify that unknown payment types are preserved through decode/encode round-trips.
Changed components
eclair-core router gossip validation (Validation.scala)LiquidityAds wire protocol codec (LiquidityAds.scala)node_announcement liquidity-ads TLV handlingInspect captured patch +64 / −37
### eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala
@@ -340,6 +340,10 @@ object Validation {
log.debug("ignoring {} (duplicate)", n)
remoteOrigins.foreach(sendDecision(_, GossipDecision.Duplicate(n)))
d
+ } else if (n.fundingRates_opt.exists(f => f.fundingRates.isEmpty || f.encodedPaymentTypes.isEmpty)) {
+ log.warning("missing funding rates for node_announcement from node_id={}", n.nodeId)
+ remoteOrigins.foreach(sendDecision(_, GossipDecision.InvalidSignature(n)))
+ d
} else if (!Announcements.checkSig(n)) {
log.warning("bad signature for {}", n)
remoteOrigins.foreach(sendDecision(_, GossipDecision.InvalidSignature(n)))
### eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala
@@ -95,6 +95,7 @@ object LiquidityAds {
sealed trait PaymentType {
// @formatter:off
def rfcName: String
+ def bitIndex: Int
override def toString: String = rfcName
// @formatter:on
}
@@ -105,13 +106,25 @@ object LiquidityAds {
object PaymentType {
// @formatter:off
/** Fees are transferred from the buyer's channel balance to the seller's during the interactive-tx construction. */
- case object FromChannelBalance extends PaymentType { override val rfcName: String = "from_channel_balance" }
+ case object FromChannelBalance extends PaymentType {
+ override val rfcName: String = "from_channel_balance"
+ override val bitIndex: Int = 0
+ }
/** Fees will be deducted from future HTLCs that will be relayed to the buyer. */
- case object FromFutureHtlc extends OnTheFlyFundingPaymentType { override val rfcName: String = "from_future_htlc" }
+ case object FromFutureHtlc extends OnTheFlyFundingPaymentType {
+ override val rfcName: String = "from_future_htlc"
+ override val bitIndex: Int = 128
+ }
/** Fees will be deducted from future HTLCs that will be relayed to the buyer, but the preimage is revealed immediately. */
- case object FromFutureHtlcWithPreimage extends OnTheFlyFundingPaymentType { override val rfcName: String = "from_future_htlc_with_preimage" }
+ case object FromFutureHtlcWithPreimage extends OnTheFlyFundingPaymentType {
+ override val rfcName: String = "from_future_htlc_with_preimage"
+ override val bitIndex: Int = 129
+ }
/** Similar to [[FromChannelBalance]] but expects HTLCs to be relayed after funding. */
- case object FromChannelBalanceForFutureHtlc extends OnTheFlyFundingPaymentType { override val rfcName: String = "from_channel_balance_for_future_htlc" }
+ case object FromChannelBalanceForFutureHtlc extends OnTheFlyFundingPaymentType {
+ override val rfcName: String = "from_channel_balance_for_future_htlc"
+ override val bitIndex: Int = 130
+ }
/** Sellers may support unknown payment types, which we must ignore. */
case class Unknown(bitIndex: Int) extends PaymentType { override val rfcName: String = s"unknown_$bitIndex" }
// @formatter:on
@@ -132,7 +145,14 @@ object LiquidityAds {
}
/** Sellers offer various rates and payment options. */
- case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) {
+ case class WillFundRates(fundingRates: List[FundingRate], encodedPaymentTypes: ByteVector) {
+ val paymentTypes: Set[PaymentType] = Set(
+ if (WillFundRates.hasPaymentType(PaymentType.FromChannelBalance.bitIndex, encodedPaymentTypes)) Some(PaymentType.FromChannelBalance) else None,
+ if (WillFundRates.hasPaymentType(PaymentType.FromFutureHtlc.bitIndex, encodedPaymentTypes)) Some(PaymentType.FromFutureHtlc) else None,
+ if (WillFundRates.hasPaymentType(PaymentType.FromFutureHtlcWithPreimage.bitIndex, encodedPaymentTypes)) Some(PaymentType.FromFutureHtlcWithPreimage) else None,
+ if (WillFundRates.hasPaymentType(PaymentType.FromChannelBalanceForFutureHtlc.bitIndex, encodedPaymentTypes)) Some(PaymentType.FromChannelBalanceForFutureHtlc) else None,
+ ).flatten
+
def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean, feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, WillFundPurchase] = {
if (!paymentTypes.contains(request.paymentDetails.paymentType)) {
Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes))
@@ -154,6 +174,27 @@ object LiquidityAds {
def findRate(requestedAmount: Satoshi): Option[FundingRate] = fundingRates.find(r => r.minAmount <= requestedAmount && requestedAmount <= r.maxAmount)
}
+ object WillFundRates {
+ def apply(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]): WillFundRates = {
+ val indexes = paymentTypes.map(_.bitIndex)
+ // When converting from BitVector to ByteVector, scodec pads right instead of left, so we make sure we pad to
+ // bytes *before* setting bits.
+ var buf = BitVector.fill(indexes.max + 1)(high = false).bytes.bits
+ indexes.foreach { i => buf = buf.set(i) }
+ val encoded = buf.reverse.bytes
+ WillFundRates(fundingRates, encoded)
+ }
+
+ private def hasPaymentType(bitIndex: Int, encoded: ByteVector): Boolean = {
+ if (bitIndex < encoded.size * 8) {
+ val offset = bitIndex % 8
+ (encoded.get(encoded.size - 1 - (bitIndex / 8)) & (0x01 << offset)) != 0
+ } else {
+ false
+ }
+ }
+ }
+
def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates], feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, Option[WillFundPurchase]] = {
(request_opt, rates_opt) match {
case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation, feeCreditUsed_opt).map(l => Some(l))
@@ -281,34 +322,9 @@ object LiquidityAds {
("signature" | bytes64)
).as[WillFund]
- private val paymentTypes: Codec[Set[PaymentType]] = bytes.xmap(
- f = { bytes =>
- bytes.bits.toIndexedSeq.reverse.zipWithIndex.collect {
- case (true, 0) => PaymentType.FromChannelBalance
- case (true, 128) => PaymentType.FromFutureHtlc
- case (true, 129) => PaymentType.FromFutureHtlcWithPreimage
- case (true, 130) => PaymentType.FromChannelBalanceForFutureHtlc
- case (true, idx) => PaymentType.Unknown(idx)
- }.toSet
- },
- g = { paymentTypes =>
- val indexes = paymentTypes.collect {
- case PaymentType.FromChannelBalance => 0
- case PaymentType.FromFutureHtlc => 128
- case PaymentType.FromFutureHtlcWithPreimage => 129
- case PaymentType.FromChannelBalanceForFutureHtlc => 130
- case PaymentType.Unknown(idx) => idx
- }
- // When converting from BitVector to ByteVector, scodec pads right instead of left, so we make sure we pad to bytes *before* setting bits.
- var buf = BitVector.fill(indexes.max + 1)(high = false).bytes.bits
- indexes.foreach { i => buf = buf.set(i) }
- buf.reverse.bytes
- }
- )
-
val willFundRates: Codec[WillFundRates] = (
("fundingRates" | listOfN(uint16, fundingRate)) ::
- ("paymentTypes" | variableSizeBytes(uint16, paymentTypes))
+ ("paymentTypes" | variableSizeBytes(uint16, bytes))
).as[WillFundRates]
}
### eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
@@ -491,7 +491,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
LiquidityAds.FundingRate(100_000 sat, 500_000 sat, 550, 100, 5_000 sat, 1000 sat),
LiquidityAds.FundingRate(500_000 sat, 5_000_000 sat, 1100, 75, 0 sat, 1500 sat),
),
- Set(LiquidityAds.PaymentType.FromChannelBalance)
+ paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance)
)
val nodeKey = PrivateKey(hex"57ac961f1b80ebfb610037bf9c96c6333699bde42257919a53974811c34649e3")
val nodeAnn = Announcements.makeNodeAnnouncement(nodeKey, "LN-Liquidity", Color(42, 117, 87), Nil, Features.empty, TimestampSecond(1713171401), Some(willFundRates))
@@ -531,7 +531,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
ByteVector32.fromValidHex("80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734"),
ByteVector32.fromValidHex("d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"),
)
- val willFundRates1 = willFundRates.copy(paymentTypes = Set(LiquidityAds.PaymentType.FromFutureHtlc))
+ val willFundRates1 = LiquidityAds.WillFundRates(fundingRates = willFundRates.fundingRates, paymentTypes = Set(LiquidityAds.PaymentType.FromFutureHtlc))
val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHashes), willFundRates1)
val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
@@ -547,7 +547,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
ByteVector32.fromValidHex("80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734"),
ByteVector32.fromValidHex("d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"),
)
- val willFundRates1 = willFundRates.copy(paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc))
+ val willFundRates1 = LiquidityAds.WillFundRates(fundingRates = willFundRates.fundingRates, paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc))
val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes), willFundRates1)
val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
@@ -560,9 +560,16 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
}
test("decode unknown liquidity ads payment types") {
- val fundingRate = LiquidityAds.FundingRate(100_000 sat, 500_000 sat, 550, 100, 5_000 sat, 0 sat)
+ val fundingRates = LiquidityAds.WillFundRates(
+ fundingRates = LiquidityAds.FundingRate(100_000 sat, 500_000 sat, 550, 100, 5_000 sat, 0 sat) :: Nil,
+ paymentTypes = Set(
+ LiquidityAds.PaymentType.FromChannelBalance,
+ LiquidityAds.PaymentType.Unknown(75),
+ LiquidityAds.PaymentType.Unknown(211),
+ )
+ )
val testCases = Map(
- hex"0001 000186a00007a120022600640000138800000000 001b 080000000000000000000000000000000008000000000000000001" -> LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.Unknown(75), LiquidityAds.PaymentType.Unknown(211))),
+ hex"0001 000186a00007a120022600640000138800000000 001b 080000000000000000000000000000000008000000000000000001" -> fundingRates,
)
for ((encoded, expected) <- testCases) {
val decoded = LiquidityAds.Codecs.willFundRates.decode(encoded.bits)
### eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala
@@ -37,7 +37,7 @@ class LiquidityAdsSpec extends AnyFunSuite {
assert(fundingRate.fees(FeeratePerByte(5 sat).perKw, 500_000 sat, 400_000 sat, isChannelCreation = false).total == 4635.sat)
assert(fundingRate.fees(FeeratePerByte(10 sat).perKw, 500_000 sat, 500_000 sat, isChannelCreation = false).total == 6260.sat)
- val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance))
+ val fundingRates = LiquidityAds.WillFundRates(fundingRates = fundingRate :: Nil, paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance))
val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates)
val fundingScript = hex"00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff"
val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true, None).map(_.willFund)Why this scored 50/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.