Disable Bolt12 recipient path fee discount (#3332)
What changed, and why it matters
This commit removes a fee-discount feature for a new kind of Lightning payment (Bolt12 offers). The old feature let the seller pay routing fees for the hidden 'blinded' path they chose for privacy. But when a payer split a payment into many tiny pieces, each piece could claim the full discount, so most of the money would go to routing fees instead of the seller. The patch disables the discount to stop that abuse. It is a defensive fix, not an active exploit being patched in deployed code, because Bolt12 merchant use is still rare.
Upgrade to the release containing this commit. If running a custom offer-handler plugin, ensure it does not set feeOverride_opt in InvoiceRequestActor.Route. Merchants relying on Bolt12 privacy paths should implement their own offer handler rather than use the default reference handler.
Security signals we found
Economic/fee griefing via MPP splitting against Bolt12 blinded-path fee discount
Recipient-chosen privacy path fees incorrectly applied per payment part instead of per payment
Feature disablement pending protocol redesign
Release notes explicitly warn custom offer-handler plugins not to set feeOverride_opt
Evidence from the diff
In DefaultOfferHandler.scala the code stops overriding blinded-path relay fees to zero (feeOverride_opt = Some(RelayFees.zero) becomes None). The release notes and comments explain that the previous design from PR #2993 did not account for multi-part payments (MPP): a payer could split the amount into many small HTLCs, each receiving the full path fee allowance, causing the aggregate routing fees to consume most or all of the intended recipient amount. The change reverts to the normal fee model where the payer pays the blinded-path fees. Tests are updated to expect positive fees again.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scalaBolt12 offer/invoice handlingBlinded path routing fee logicInspect captured patch +16 / −12
diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md
index bc1f434..3153d56 100644
--- a/docs/release-notes/eclair-vnext.md
+++ b/docs/release-notes/eclair-vnext.md
@@ -9,6 +9,15 @@
With this release, eclair requires using Bitcoin Core 31.x.
Newer versions of Bitcoin Core may be used, but have not been extensively tested.
+### Disable blinded path fee discount for Bolt12
+
+We've disabled blinded path fee discount introduced in #2993 for Bolt12 payments.
+It doesn't work well with MPP and need to be re-designed.
+If you're using a custom offer-handler plugin, make sure you don't set `feeOverride_opt`
+in the `InvoiceRequestActor.Route` you create, otherwise your node will be at risk.
+
+See #3332 for more details.
+
### Configuration changes
<insert changes>
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala
index 3239381..7ce4d91 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala
@@ -118,10 +118,11 @@ object DefaultOfferHandler {
val hops = routes(i % routes.length)
// We always pad blinded paths to the configured length, using dummy hops if necessary.
val dummyHops = Seq.fill(nodeParams.offersConfig.paymentPathLength - hops.length)(ChannelHop.dummy(nodeParams.nodeId, 0 msat, 0, CltvExpiryDelta(0)))
- // We always override the fees of the payment path: the payer shouldn't be paying for our privacy.
- // Note that we told the router to only find paths with a lower cltv_expiry_delta than what we'll be using,
- // which ensures that we won't reject payments because of their expiry.
- InvoiceRequestActor.Route(hops ++ dummyHops, nodeParams.channelConf.maxExpiryDelta, feeOverride_opt = Some(RelayFees.zero), cltvOverride_opt = Some(nodeParams.offersConfig.paymentPathCltvExpiryDelta))
+ // Note that we currently don't override the fees of the payment path.
+ // While this would be more fair (payers shouldn't be paying for our privacy), this doesn't work well with MPP.
+ // Payers could abuse it by splitting payments into many tiny parts, where everything goes to routing fees.
+ // We'll need a more robust protocol to allow that feature to be safely activated.
+ InvoiceRequestActor.Route(hops ++ dummyHops, nodeParams.channelConf.maxExpiryDelta, feeOverride_opt = None, cltvOverride_opt = Some(nodeParams.offersConfig.paymentPathCltvExpiryDelta))
})
}
}
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 08ba8c4..6ba9ee3 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
@@ -807,8 +807,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience {
val payment = payOffer(alice, offer, amount)
assert(payment.isInstanceOf[PaymentSent])
- // For offers managed by eclair, the fees of the blinded path are paid by the recipient, not by the payer.
- assert(payment.asInstanceOf[PaymentSent].feesPaid == 0.msat)
+ assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat)
assert(payment.asInstanceOf[PaymentSent].parts.nonEmpty)
payment.asInstanceOf[PaymentSent].parts.foreach(p => {
val blinded = p.route.flatMap(_.lastOption).get
@@ -831,12 +830,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience {
val payment = payOffer(alice, offer, amount)
assert(payment.isInstanceOf[PaymentSent])
- // For offers managed by eclair, the fees of the blinded path are paid by the recipient, not by the payer.
- // If the payer is part of the blinded path, it means that the recipient is refunding the fees of the payer's
- // first hop: but this hop is not part of the payer fees, since the channel belongs to the payer, so it looks
- // like the payment used negative fees. In reality, this simply means that we obtained the preimage while paying
- // less than the invoice amount, which is fine.
- assert(payment.asInstanceOf[PaymentSent].feesPaid < 0.msat)
+ assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat)
assert(payment.asInstanceOf[PaymentSent].parts.nonEmpty)
payment.asInstanceOf[PaymentSent].parts.foreach(p => {
val blinded = p.route.flatMap(_.lastOption).get
Why this scored 60/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.