What changed, and why it matters
This commit fixes a rounding bug in Eclair's handling of Bolt12 (a newer Lightning Network invoice format) blinded-path fees. When a recipient hides their real node behind a private payment path, they can choose to pay those path fees themselves rather than charging the payer. The code that computed the maximum such fee used the wrong amount in some rounding cases, causing Eclair to reject valid incoming payments unnecessarily. The fix uses the larger of the invoice amount and the actual received amount when computing the fee limit, and adjusts default/test settings accordingly.
Treat as a functional bug fix with minor availability impact. Reviewers should verify that nodeFee rounding behavior is now bounded correctly for all amount combinations and that lowering payment-path-length does not weaken path privacy beyond intended configuration. No immediate security patch urgency is indicated by the diff alone.
Security signals we found
Logic error in fee-bound computation for Bolt12 blinded paths
Could cause denial of service for legitimate incoming payments
No direct funds loss or theft path evident from diff
Fix is small and targeted to rounding/amount selection
Evidence from the diff
In OfferManager.scala, maxRecipientPathFees was computed as nodeFee(metadata.recipientPathFees, amount). Because nodeFee applies a proportional fee with millisecond-resolution rounding, using the actual received amount when it is smaller than the invoice amount could yield a fee cap lower than the amount actually deducted from the payment, leading the handler to reject the payment. The fix changes the base to Seq(amount, metadata.amount).max so the cap is computed against the larger reference amount. reference.conf lowers payment-path-length from 4 to 2, and tests are relaxed to allow route lengths >= the configured minimum rather than exact equality.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scalaeclair-core/src/main/resources/reference.confBolt12 offer/invoice payment acceptance pathInspect captured patch +8 / −8
diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf
index 87741f1..ad4d14e 100644
--- a/eclair-core/src/main/resources/reference.conf
+++ b/eclair-core/src/main/resources/reference.conf
@@ -734,11 +734,10 @@ eclair {
offers {
// Minimum length of an offer blinded path when hiding our real node id
message-path-min-length = 2
-
// Number of payment paths to put in Bolt12 invoices when hiding our real node id
payment-path-count = 2
// Length of payment paths to put in Bolt12 invoices when hiding our real node id
- payment-path-length = 4
+ payment-path-length = 2
// Expiry delta of payment paths to put in Bolt12 invoices when hiding our real node id
payment-path-expiry-delta = 500
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala
index f4a6edb..cfb6509 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala
@@ -285,7 +285,7 @@ object OfferManager {
val minimalInvoice = MinimalBolt12Invoice(offer, nodeParams.chainHash, metadata.amount, metadata.quantity, Crypto.sha256(metadata.preimage), metadata.payerKey, metadata.createdAt, additionalTlvs, customTlvs)
val incomingPayment = IncomingBlindedPayment(minimalInvoice, metadata.preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending)
// We may be deducing some of the blinded path fees from the received amount.
- val maxRecipientPathFees = nodeFee(metadata.recipientPathFees, amount)
+ val maxRecipientPathFees = nodeFee(metadata.recipientPathFees, Seq(amount, metadata.amount).max)
replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment, maxRecipientPathFees)
Behaviors.stopped
case RejectPayment(reason) =>
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala
index 74ed201..08ba8c4 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala
@@ -783,7 +783,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience {
assert(offer.nodeId.isEmpty)
assert(offer.contactInfos.size == 1)
assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(carol.nodeId))
- assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length == carol.nodeParams.offersConfig.messagePathMinLength)
+ assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length >= carol.nodeParams.offersConfig.messagePathMinLength)
assert(offer.description.contains("test offer"))
assert(offer.amount.contains(amount))
@@ -801,7 +801,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience {
assert(offer.nodeId.isEmpty)
assert(offer.contactInfos.size == 1)
assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(bob.nodeId))
- assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length == carol.nodeParams.offersConfig.messagePathMinLength)
+ assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length >= carol.nodeParams.offersConfig.messagePathMinLength)
assert(offer.description.contains("test offer"))
assert(offer.amount.contains(amount))
@@ -825,7 +825,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience {
val offer = createOffer(carol, description_opt = Some("test offer"), amount_opt = Some(amount), issuer_opt = None, blindedPathsFirstNodeId_opt = Some(alice.nodeId))
assert(offer.nodeId.isEmpty)
assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(alice.nodeId))
- assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length == carol.nodeParams.offersConfig.messagePathMinLength)
+ assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.length >= carol.nodeParams.offersConfig.messagePathMinLength)
assert(offer.description.contains("test offer"))
assert(offer.amount.contains(amount))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala
index 16fad76..019228c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala
@@ -33,7 +33,7 @@ import fr.acinq.eclair.router.Router.ChannelHop
import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer}
import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.RouteBlindingDecryptedData
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, amountAfterFee, randomBytes32, randomKey}
+import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, amountAfterFee, nodeFee, randomBytes32, randomKey}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
import scodec.bits.{ByteVector, HexStringSyntax}
@@ -332,6 +332,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash)
assert(incomingPayment.invoice.nodeId == nodeParams.nodeId)
assert(incomingPayment.invoice.paymentHash == invoice.paymentHash)
- assert(maxRecipientPathFees == paymentPayload.amount - amountReceived)
+ assert(maxRecipientPathFees >= paymentPayload.amount - amountReceived)
+ assert(maxRecipientPathFees == nodeFee(1000 msat, 200, amount))
}
}
Why this scored 32/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.