Skip to content

Commit 9456236

Browse files
Allow recipient to pay for blinded route fees (#2993)
When using blinded payment paths, the fees of the blinded path should be paid by the recipient, not the sender. - It is more fair: the sender chooses the non blinded part of the path and pays the corresponding fees, the recipient chooses the blinded part of the path and pays the corresponding fees. - It is more private: the sender does not learn the actual fees for the path and can't use this information to unblind the path. Co-authored-by: t-bast <bastien@acinq.fr>
1 parent 4ad2f99 commit 9456236

23 files changed

+508
-303
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ class Setup(val datadir: File,
357357
}
358358
dbEventHandler = system.actorOf(SimpleSupervisor.props(DbEventHandler.props(nodeParams), "db-event-handler", SupervisorStrategy.Resume))
359359
register = system.actorOf(SimpleSupervisor.props(Register.props(), "register", SupervisorStrategy.Resume))
360-
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, router, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
360+
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
361361
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
362362
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
363363
peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager")

eclair-core/src/main/scala/fr/acinq/eclair/package.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ package object eclair {
7171

7272
def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount)
7373

74+
/**
75+
* @param baseFee fixed fee
76+
* @param proportionalFee proportional fee (millionths)
77+
* @param incomingAmount incoming payment amount
78+
* @return the amount that a node should forward after paying itself the base and proportional fees
79+
*/
80+
def amountAfterFee(baseFee: MilliSatoshi, proportionalFee: Long, incomingAmount: MilliSatoshi): MilliSatoshi =
81+
((incomingAmount - baseFee).toLong * 1_000_000 + 1_000_000 + proportionalFee - 1).msat / (1_000_000 + proportionalFee)
82+
83+
def amountAfterFee(relayFees: RelayFees, incomingAmount: MilliSatoshi): MilliSatoshi = amountAfterFee(relayFees.feeBase, relayFees.feeProportionalMillionths, incomingAmount)
84+
7485
implicit class MilliSatoshiLong(private val n: Long) extends AnyVal {
7586
def msat = MilliSatoshi(n)
7687
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ object IncomingPaymentPacket {
221221
case payload if payload.paymentConstraints_opt.exists(c => add.amountMsat < c.minAmount) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
222222
case payload if payload.paymentConstraints_opt.exists(c => c.maxCltvExpiry < add.cltvExpiry) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
223223
case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
224-
case payload if add.amountMsat < payload.amount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
225224
case payload if add.cltvExpiry < payload.expiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
226225
case payload => Right(FinalPacket(add, payload))
227226
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,21 @@ import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
2020
import akka.actor.typed.{ActorRef, Behavior}
2121
import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey
2222
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto}
23+
import fr.acinq.eclair.EncodedNodeId.ShortChannelIdDir
2324
import fr.acinq.eclair.crypto.Sphinx.RouteBlinding
2425
import fr.acinq.eclair.db.{IncomingBlindedPayment, IncomingPaymentStatus, PaymentType}
2526
import fr.acinq.eclair.message.{OnionMessages, Postman}
26-
import fr.acinq.eclair.payment.MinimalBolt12Invoice
2727
import fr.acinq.eclair.payment.offer.OfferPaymentMetadata.MinimalInvoiceData
2828
import fr.acinq.eclair.payment.receive.MultiPartHandler
2929
import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivingRoute}
30+
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
31+
import fr.acinq.eclair.payment.{Bolt12Invoice, MinimalBolt12Invoice}
32+
import fr.acinq.eclair.router.BlindedRouteCreation.aggregatePaymentInfo
33+
import fr.acinq.eclair.router.Router
3034
import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, InvoiceTlv, Offer}
3135
import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload
3236
import fr.acinq.eclair.wire.protocol._
33-
import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams, TimestampMilli, TimestampSecond, randomBytes32}
37+
import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32}
3438
import scodec.bits.ByteVector
3539

3640
import scala.concurrent.duration.FiniteDuration
@@ -61,7 +65,7 @@ object OfferManager {
6165

6266
case class RequestInvoice(messagePayload: MessageOnion.InvoiceRequestPayload, blindedKey: PrivateKey, postman: ActorRef[Postman.SendMessage]) extends Command
6367

64-
case class ReceivePayment(replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], paymentHash: ByteVector32, payload: FinalPayload.Blinded) extends Command
68+
case class ReceivePayment(replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], paymentHash: ByteVector32, payload: FinalPayload.Blinded, amountReceived: MilliSatoshi) extends Command
6569

6670
/**
6771
* Offer handlers must be implemented in separate plugins and respond to these two `HandlerCommand`.
@@ -89,15 +93,15 @@ object OfferManager {
8993

9094
private case class RegisteredOffer(offer: Offer, nodeKey: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand])
9195

92-
def apply(nodeParams: NodeParams, router: akka.actor.ActorRef, paymentTimeout: FiniteDuration): Behavior[Command] = {
96+
def apply(nodeParams: NodeParams, paymentTimeout: FiniteDuration): Behavior[Command] = {
9397
Behaviors.setup { context =>
9498
Behaviors.withMdc(Logs.mdc(category_opt = Some(Logs.LogCategory.PAYMENT))) {
95-
new OfferManager(nodeParams, router, paymentTimeout, context).normal(Map.empty)
99+
new OfferManager(nodeParams, paymentTimeout, context).normal(Map.empty)
96100
}
97101
}
98102
}
99103

100-
private class OfferManager(nodeParams: NodeParams, router: akka.actor.ActorRef, paymentTimeout: FiniteDuration, context: ActorContext[Command]) {
104+
private class OfferManager(nodeParams: NodeParams, paymentTimeout: FiniteDuration, context: ActorContext[Command]) {
101105
def normal(registeredOffers: Map[ByteVector32, RegisteredOffer]): Behavior[Command] = {
102106
Behaviors.receiveMessage {
103107
case RegisterOffer(offer, nodeKey, pathId_opt, handler) =>
@@ -108,19 +112,19 @@ object OfferManager {
108112
registeredOffers.get(messagePayload.invoiceRequest.offer.offerId) match {
109113
case Some(registered) if registered.pathId_opt.map(_.bytes) == messagePayload.pathId_opt && messagePayload.invoiceRequest.isValid =>
110114
context.log.debug("received valid invoice request for offerId={}", messagePayload.invoiceRequest.offer.offerId)
111-
val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey.getOrElse(blindedKey), router, messagePayload.replyPath, postman))
115+
val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey.getOrElse(blindedKey), messagePayload.replyPath, postman))
112116
child ! InvoiceRequestActor.RequestInvoice
113117
case _ => context.log.debug("offer {} is not registered or invoice request is invalid", messagePayload.invoiceRequest.offer.offerId)
114118
}
115119
Behaviors.same
116-
case ReceivePayment(replyTo, paymentHash, payload) =>
120+
case ReceivePayment(replyTo, paymentHash, payload, amountReceived) =>
117121
MinimalInvoiceData.decode(payload.pathId) match {
118122
case Some(signed) =>
119123
registeredOffers.get(signed.offerId) match {
120124
case Some(RegisteredOffer(offer, _, _, handler)) =>
121125
MinimalInvoiceData.verify(nodeParams.nodeId, signed) match {
122126
case Some(metadata) if Crypto.sha256(metadata.preimage) == paymentHash =>
123-
val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, paymentTimeout))
127+
val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, amountReceived, paymentTimeout))
124128
handler ! HandlePayment(child, signed.offerId, metadata.pluginData_opt)
125129
case Some(_) => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"preimage does not match payment hash for offer ${signed.offerId.toHex}")
126130
case None => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"invalid signature for metadata for offer ${signed.offerId.toHex}")
@@ -150,33 +154,53 @@ object OfferManager {
150154
* @param customTlvs custom TLVs to add to the invoice.
151155
*/
152156
case class ApproveRequest(amount: MilliSatoshi,
153-
routes: Seq[ReceivingRoute],
157+
routes: Seq[Route],
154158
pluginData_opt: Option[ByteVector] = None,
155159
additionalTlvs: Set[InvoiceTlv] = Set.empty,
156160
customTlvs: Set[GenericTlv] = Set.empty) extends Command
157161

162+
/**
163+
* @param recipientPaysFees If true, fees for the blinded route will be hidden to the payer and paid by the recipient.
164+
*/
165+
case class Route(hops: Seq[Router.ChannelHop], recipientPaysFees: Boolean, maxFinalExpiryDelta: CltvExpiryDelta, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) {
166+
def finalize(nodePriv: PrivateKey, preimage: ByteVector32, amount: MilliSatoshi, invoiceRequest: InvoiceRequest, minFinalExpiryDelta: CltvExpiryDelta, pluginData_opt: Option[ByteVector]): ReceivingRoute = {
167+
val (paymentInfo, metadata) = if (recipientPaysFees) {
168+
val realPaymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta)
169+
val recipientFees = RelayFees(realPaymentInfo.feeBase, realPaymentInfo.feeProportionalMillionths)
170+
val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, recipientFees, pluginData_opt)
171+
val paymentInfo = realPaymentInfo.copy(feeBase = 0 msat, feeProportionalMillionths = 0)
172+
(paymentInfo, metadata)
173+
} else {
174+
val paymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta)
175+
val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, RelayFees.zero, pluginData_opt)
176+
(paymentInfo, metadata)
177+
}
178+
val pathId = MinimalInvoiceData.encode(nodePriv, invoiceRequest.offer.offerId, metadata)
179+
ReceivingRoute(hops, pathId, maxFinalExpiryDelta, paymentInfo, shortChannelIdDir_opt)
180+
}
181+
}
182+
158183
/**
159184
* Sent by the offer handler to reject the request. For instance because stock has been exhausted.
160185
*/
161186
case class RejectRequest(message: String) extends Command
162187

163-
private case class WrappedInvoiceResponse(response: CreateInvoiceActor.Bolt12InvoiceResponse) extends Command
188+
private case class WrappedInvoiceResponse(invoice: Bolt12Invoice) extends Command
164189

165190
private case class WrappedOnionMessageResponse(response: Postman.OnionMessageResponse) extends Command
166191

167192
def apply(nodeParams: NodeParams,
168193
invoiceRequest: InvoiceRequest,
169194
offerHandler: ActorRef[HandleInvoiceRequest],
170195
nodeKey: PrivateKey,
171-
router: akka.actor.ActorRef,
172196
pathToSender: RouteBlinding.BlindedRoute,
173197
postman: ActorRef[Postman.SendMessage]): Behavior[Command] = {
174198
Behaviors.setup { context =>
175199
Behaviors.withMdc(Logs.mdc(category_opt = Some(Logs.LogCategory.PAYMENT))) {
176200
Behaviors.receiveMessagePartial {
177201
case RequestInvoice =>
178202
offerHandler ! HandleInvoiceRequest(context.self, invoiceRequest)
179-
new InvoiceRequestActor(nodeParams, invoiceRequest, nodeKey, router, pathToSender, postman, context).waitForHandler()
203+
new InvoiceRequestActor(nodeParams, invoiceRequest, nodeKey, pathToSender, postman, context).waitForHandler()
180204
}
181205
}
182206
}
@@ -185,7 +209,6 @@ object OfferManager {
185209
private class InvoiceRequestActor(nodeParams: NodeParams,
186210
invoiceRequest: InvoiceRequest,
187211
nodeKey: PrivateKey,
188-
router: akka.actor.ActorRef,
189212
pathToSender: RouteBlinding.BlindedRoute,
190213
postman: ActorRef[Postman.SendMessage],
191214
context: ActorContext[Command]) {
@@ -197,9 +220,8 @@ object OfferManager {
197220
waitForSent()
198221
case ApproveRequest(amount, routes, pluginData_opt, additionalTlvs, customTlvs) =>
199222
val preimage = randomBytes32()
200-
val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, pluginData_opt)
201-
val pathId = MinimalInvoiceData.encode(nodeParams.privateKey, invoiceRequest.offer.offerId, metadata)
202-
val receivePayment = MultiPartHandler.ReceiveOfferPayment(context.messageAdapter[CreateInvoiceActor.Bolt12InvoiceResponse](WrappedInvoiceResponse), nodeKey, invoiceRequest, routes, router, preimage, pathId, additionalTlvs, customTlvs)
223+
val receivingRoutes = routes.map(_.finalize(nodeParams.privateKey, preimage, amount, invoiceRequest, nodeParams.channelConf.minFinalExpiryDelta, pluginData_opt))
224+
val receivePayment = MultiPartHandler.ReceiveOfferPayment(context.messageAdapter[Bolt12Invoice](WrappedInvoiceResponse), nodeKey, invoiceRequest, receivingRoutes, preimage, additionalTlvs, customTlvs)
203225
val child = context.spawnAnonymous(CreateInvoiceActor(nodeParams))
204226
child ! CreateInvoiceActor.CreateBolt12Invoice(receivePayment)
205227
waitForInvoice()
@@ -208,16 +230,10 @@ object OfferManager {
208230

209231
private def waitForInvoice(): Behavior[Command] = {
210232
Behaviors.receiveMessagePartial {
211-
case WrappedInvoiceResponse(invoiceResponse) =>
212-
invoiceResponse match {
213-
case CreateInvoiceActor.InvoiceCreated(invoice) =>
214-
context.log.debug("invoice created for offerId={} invoice={}", invoice.invoiceRequest.offer.offerId, invoice.toString)
215-
postman ! Postman.SendMessage(OfferTypes.BlindedPath(pathToSender), OnionMessages.RoutingStrategy.FindRoute, TlvStream(OnionMessagePayloadTlv.Invoice(invoice.records)), expectsReply = false, context.messageAdapter[Postman.OnionMessageResponse](WrappedOnionMessageResponse))
216-
waitForSent()
217-
case f: CreateInvoiceActor.InvoiceCreationFailed =>
218-
context.log.debug("invoice creation failed: {}", f.message)
219-
Behaviors.stopped
220-
}
233+
case WrappedInvoiceResponse(invoice) =>
234+
context.log.debug("invoice created for offerId={} invoice={}", invoice.invoiceRequest.offer.offerId, invoice.toString)
235+
postman ! Postman.SendMessage(OfferTypes.BlindedPath(pathToSender), OnionMessages.RoutingStrategy.FindRoute, TlvStream(OnionMessagePayloadTlv.Invoice(invoice.records)), expectsReply = false, context.messageAdapter[Postman.OnionMessageResponse](WrappedOnionMessageResponse))
236+
waitForSent()
221237
}
222238
}
223239

@@ -234,7 +250,8 @@ object OfferManager {
234250
sealed trait Command
235251

236252
/**
237-
* Sent by the offer handler. Causes the creation of a dummy invoice that matches as best as possible the actual invoice for this payment (since the actual invoice is not stored) and will be used in the payment handler.
253+
* Sent by the offer handler. Causes the creation of a dummy invoice that matches as best as possible the actual
254+
* invoice for this payment (since the actual invoice is not stored) and will be used in the payment handler.
238255
*
239256
* @param additionalTlvs additional TLVs to add to the dummy invoice. Should be the same as what was used for the actual invoice.
240257
* @param customTlvs custom TLVs to add to the dummy invoice. Should be the same as what was used for the actual invoice.
@@ -246,14 +263,21 @@ object OfferManager {
246263
*/
247264
case class RejectPayment(reason: String) extends Command
248265

249-
def apply(nodeParams: NodeParams, replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], offer: Offer, metadata: MinimalInvoiceData, timeout: FiniteDuration): Behavior[Command] = {
266+
def apply(nodeParams: NodeParams,
267+
replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command],
268+
offer: Offer,
269+
metadata: MinimalInvoiceData,
270+
amount: MilliSatoshi,
271+
timeout: FiniteDuration): Behavior[Command] = {
250272
Behaviors.setup { context =>
251273
context.scheduleOnce(timeout, context.self, RejectPayment("plugin timeout"))
252274
Behaviors.receiveMessage {
253275
case AcceptPayment(additionalTlvs, customTlvs) =>
254276
val minimalInvoice = MinimalBolt12Invoice(offer, nodeParams.chainHash, metadata.amount, metadata.quantity, Crypto.sha256(metadata.preimage), metadata.payerKey, metadata.createdAt, additionalTlvs, customTlvs)
255277
val incomingPayment = IncomingBlindedPayment(minimalInvoice, metadata.preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending)
256-
replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment)
278+
// We may be deducing some of the blinded path fees from the received amount.
279+
val maxRecipientPathFees = nodeFee(metadata.recipientPathFees, amount)
280+
replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment, maxRecipientPathFees)
257281
Behaviors.stopped
258282
case RejectPayment(reason) =>
259283
replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(reason)

0 commit comments

Comments
 (0)