Reject offers with some fields present but empty (#3175)
What changed, and why it matters
This commit tightens validation of Bitcoin Lightning 'Bolt 12' offers and invoices. Previously, an offer or invoice could include certain list fields (such as payment paths or supported chains) that were technically present but contained zero entries. The code now rejects these empty-but-present fields during parsing. The change prevents malformed or unpayable offers/invoices from being accepted, which could otherwise confuse wallets, break routing, or be used to probe implementations.
Treat this as a defensive hardening patch. Reviewers should verify that the new `nonEmptyList` codec does not break legitimate offers/invoices that omit these fields entirely, and that downstream consumers no longer assume an empty list is valid. Consider whether other list TLVs in Bolt 12 (or related protocols) need the same treatment.
Security signals we found
Input validation hardening for TLV parsing
Rejection of malformed Bolt 12 offers/invoices that cannot be paid
Prevention of empty-but-present routing/path fields that could cause downstream logic errors
Speculative risk of parsing ambiguity between absent and empty list fields
Evidence from the diff
The patch adds a nonEmptyList codec helper in CommonCodecs.scala and applies it to offer_chains, offer_paths, invoice_paths, and invoice_blindedpay in OfferCodecs.scala. It also updates validation in OfferTypes.validate and Bolt12Invoice.fromString so that an empty-but-present InvoicePaths/OfferPaths is treated as missing rather than valid. Tests are added for an empty invoice_paths and for empty offer_paths/offer_chains. The effect is to fail deserialization/validation when these TLVs are present with empty contents.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scalaeclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scalaeclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scalaeclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scalaInspect captured patch +29 / −7
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala
index dfe18d4..ae65046 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala
@@ -127,7 +127,7 @@ object Bolt12Invoice {
_ -> ()
)
if (records.get[InvoiceAmount].isEmpty) return Left(MissingRequiredTlv(UInt64(170)))
- if (records.get[InvoicePaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(160)))
+ if (records.get[InvoicePaths].isEmpty) return Left(MissingRequiredTlv(UInt64(160)))
if (records.get[InvoiceBlindedPay].map(_.paymentInfo.length) != records.get[InvoicePaths].map(_.paths.length)) return Left(MissingRequiredTlv(UInt64(162)))
if (records.get[InvoiceNodeId].isEmpty) return Left(MissingRequiredTlv(UInt64(176)))
if (records.get[InvoiceCreatedAt].isEmpty) return Left(MissingRequiredTlv(UInt64(164)))
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala
index 29894f8..8fad3ef 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala
@@ -205,4 +205,12 @@ object CommonCodecs {
(bits: BitVector) => Attempt.fromTry(Try(codec.decode(bits))).flatten
)
+ def nonEmptyList[A](codec: Codec[A], name: String): Codec[Seq[A]] =
+ list(codec).narrow(l => {
+ if (l.nonEmpty) {
+ Attempt.successful(l.toSeq)
+ } else {
+ Attempt.failure(Err(s"$name must not be empty"))
+ }
+ }, _.toList)
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala
index b8f7cf7..73e3d1e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala
@@ -16,7 +16,6 @@
package fr.acinq.eclair.wire.protocol
-import fr.acinq.bitcoin.scalacompat.BlockHash
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.{BlindedHop, BlindedRoute}
import fr.acinq.eclair.wire.protocol.CommonCodecs._
@@ -30,7 +29,7 @@ import java.util.Currency
import scala.util.Try
object OfferCodecs {
- private val offerChains: Codec[OfferChains] = tlvField(list(blockHash).xmap[Seq[BlockHash]](_.toSeq, _.toList))
+ private val offerChains: Codec[OfferChains] = tlvField(nonEmptyList(blockHash, "offer_chains"))
private val offerMetadata: Codec[OfferMetadata] = tlvField(bytes)
@@ -76,7 +75,7 @@ object OfferCodecs {
("firstPathKey" | publicKey) ::
("path" | blindedNodesCodec)).as[BlindedRoute]
- private val offerPaths: Codec[OfferPaths] = tlvField(list(blindedRouteCodec).xmap[Seq[BlindedRoute]](_.toSeq, _.toList))
+ private val offerPaths: Codec[OfferPaths] = tlvField(nonEmptyList(blindedRouteCodec, "offer_paths"))
private val offerIssuer: Codec[OfferIssuer] = tlvField(utf8)
@@ -138,7 +137,7 @@ object OfferCodecs {
.typecase(UInt64(240), signature)
).complete)
- private val invoicePaths: Codec[InvoicePaths] = tlvField(list(blindedRouteCodec).xmap[Seq[BlindedRoute]](_.toSeq, _.toList))
+ private val invoicePaths: Codec[InvoicePaths] = tlvField(nonEmptyList(blindedRouteCodec, "invoice_paths"))
val paymentInfo: Codec[PaymentInfo] =
(("fee_base_msat" | millisatoshi32) ::
@@ -148,7 +147,7 @@ object OfferCodecs {
("htlc_maximum_msat" | millisatoshi) ::
("features" | variableSizeBytes(uint16, bytes))).as[PaymentInfo]
- private val invoiceBlindedPay: Codec[InvoiceBlindedPay] = tlvField(list(paymentInfo).xmap[Seq[PaymentInfo]](_.toSeq, _.toList))
+ private val invoiceBlindedPay: Codec[InvoiceBlindedPay] = tlvField(nonEmptyList(paymentInfo, "invoice_blindedpay"))
private val invoiceCreatedAt: Codec[InvoiceCreatedAt] = tlvField(tu64overflow.as[TimestampSecond])
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala
index c4e3852..e15d213 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala
@@ -308,7 +308,7 @@ object OfferTypes {
def validate(records: TlvStream[OfferTlv]): Either[InvalidTlvPayload, Offer] = {
if (records.get[OfferDescription].isEmpty && records.get[OfferAmount].nonEmpty) return Left(MissingRequiredTlv(UInt64(10)))
- if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(22)))
+ if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].isEmpty) return Left(MissingRequiredTlv(UInt64(22)))
if (records.get[OfferCurrency].nonEmpty && records.get[OfferAmount].isEmpty) return Left(MissingRequiredTlv(UInt64(8)))
if (records.unknown.exists(!isOfferTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isOfferTlv(_)).get.tag))
Right(Offer(records))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala
index 2a40ff4..a1389b7 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala
@@ -383,4 +383,9 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
assert(invoice.checkSignature())
assert(invoice.amount == 1000000000.msat)
}
+
+ test("invoice paths is set but and empty") {
+ val invoiceWithEmptyPaths = "lni1qqx2n6mw2fh2ckwdnwylkgqzypp5jl7hlqnf2ugg7j3slkwwcwht57vhyzzwjr4dq84rxzgqqqqqqzqrq83yqzscd9h8vmmfvdjjqamfw35zqmtpdeujqenfv4kxgucvqqfq2ctvd93k293pq0zxw03kpc8tc2vv3kfdne0kntqhq8p70wtdncwq2zngaqp529mmc5pqgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqq9yqcpufq9vqfetqssyj5djm6dz0zzr8eprw9gu762k75f3lgm96gzwn994peh48k6xalctyr5jfmdyppx7cneqvqsyqaqqz3qpfqyv2sqd04xqg8pp2pq2x236nzneyzqxhct9y7unhcupeukwgf5xzhq0f0nuy6v6vej2dq65qcpufq2cysyqqzpy02klqrqqz8t8twx39z77cq6uq9syypugee7xc8qa0pf3jxe9k0976dvzuqu8eaedk0pcpg2dr5qx3gh008sgrn58w7cg2qhcunaapk9j6patmtda7nhqhzvwv6hflxygyrrglpqka8l6zfhfhprxazkufcn88rl07yxfp5mvjl70etp2pzdkhud3ekul5qnjq46hg"
+ assert(Bolt12Invoice.fromString(invoiceWithEmptyPaths).isFailure)
+ }
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala
index b934377..7bc7ef1 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala
@@ -350,4 +350,14 @@ class OfferTypesSpec extends AnyFunSuite {
assert(OfferCodecs.offerCurrency.decode(encode("XAU")).isFailure)
assert(OfferCodecs.offerCurrency.decode(hex"ffffff".bits).isFailure)
}
+
+ test("empty fields") {
+ val invalidOffers = Seq(
+ Offer(TlvStream(OfferPaths(Nil))),
+ Offer(TlvStream(OfferNodeId(randomKey().publicKey), OfferChains(Nil))),
+ )
+ for (invalidOffer <- invalidOffers) {
+ assert(Offer.decode(invalidOffer.toString).isFailure)
+ }
+ }
}
Why this scored 52/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.