Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ object Sphinx extends Logging {
val subsequentNodes: Seq[BlindedNode] = blindedNodes.tail
val blindedNodeIds: Seq[PublicKey] = blindedNodes.map(_.blindedPublicKey)
val encryptedPayloads: Seq[ByteVector] = blindedNodes.map(_.encryptedPayload)
val length: Int = blindedNodes.length - 1
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package fr.acinq.eclair.db
import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.Router.{ChannelHop, Hop, NodeHop}
import fr.acinq.eclair.router.Router.{BlindedHop, ChannelHop, Hop, NodeHop}
import fr.acinq.eclair.{MilliSatoshi, Paginated, ShortChannelId, TimestampMilli}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -226,6 +226,7 @@ object HopSummary {
def apply(h: Hop): HopSummary = {
val shortChannelId = h match {
case ch: ChannelHop => Some(ch.shortChannelId)
case _: BlindedHop => None
case _: NodeHop => None
}
HopSummary(h.nodeId, h.nextNodeId, shortChannelId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.message.OnionMessages
import fr.acinq.eclair.payment.PaymentFailure.PaymentFailedSummary
import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.Router.{HopRelayParams, NodeHop, Route}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.transactions.DirectedHtlc
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.MessageOnionCodecs.blindedRouteCodec
Expand Down Expand Up @@ -296,12 +296,14 @@ object ColorSerializer extends MinimalSerializer({
// @formatter:off
private sealed trait HopJson
private case class ChannelHopJson(nodeId: PublicKey, nextNodeId: PublicKey, source: HopRelayParams) extends HopJson
private case class BlindedHopJson(nodeId: PublicKey, nextNodeId: PublicKey, paymentInfo: OfferTypes.PaymentInfo) extends HopJson
private case class NodeHopJson(nodeId: PublicKey, nextNodeId: PublicKey, fee: MilliSatoshi, cltvExpiryDelta: CltvExpiryDelta) extends HopJson
private case class RouteFullJson(amount: MilliSatoshi, hops: Seq[HopJson])
object RouteFullSerializer extends ConvertClassSerializer[Route](route => {
val channelHops = route.hops.map(h => ChannelHopJson(h.nodeId, h.nextNodeId, h.params))
val finalHop_opt = route.finalHop_opt.map {
case h: NodeHop => NodeHopJson(h.nodeId, h.nextNodeId, h.fee, h.cltvExpiryDelta)
case h: BlindedHop => BlindedHopJson(h.nodeId, h.nextNodeId, h.paymentInfo)
}
RouteFullJson(route.amount, channelHops ++ finalHop_opt.toSeq)
})
Expand All @@ -315,6 +317,8 @@ object RouteNodeIdsSerializer extends ConvertClassSerializer[Route](route => {
val finalNodeIds = route.finalHop_opt match {
case Some(hop: NodeHop) if channelNodeIds.nonEmpty => Seq(hop.nextNodeId)
case Some(hop: NodeHop) => Seq(hop.nodeId, hop.nextNodeId)
case Some(hop: BlindedHop) if channelNodeIds.nonEmpty => hop.route.blindedNodeIds.tail
case Some(hop: BlindedHop) => hop.route.introductionNodeId +: hop.route.blindedNodeIds.tail
case None => Nil
}
RouteNodeIdsJson(route.amount, channelNodeIds ++ finalNodeIds)
Expand All @@ -325,6 +329,7 @@ object RouteShortChannelIdsSerializer extends ConvertClassSerializer[Route](rout
val hops = route.hops.map(_.shortChannelId)
val finalHop = route.finalHop_opt.map {
case _: NodeHop => "trampoline"
case _: BlindedHop => "blinded"
}
RouteShortChannelIdsJson(route.amount, hops, finalHop)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import fr.acinq.eclair.payment.send.PaymentError.RetryExhausted
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig
import fr.acinq.eclair.payment.send.{ClearRecipient, Recipient}
import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.router.Router.{ChannelDesc, ChannelHop, Hop, Ignore}
import fr.acinq.eclair.wire.protocol.{ChannelDisabled, ChannelUpdate, Node, TemporaryChannelFailure}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{MilliSatoshi, ShortChannelId, TimestampMilli}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -183,11 +183,15 @@ object PaymentFailure {
.isDefined

/** Ignore the channel outgoing from the given nodeId in the given route. */
private def ignoreNodeOutgoingChannel(nodeId: PublicKey, hops: Seq[Hop], ignore: Ignore): Ignore = {
private def ignoreNodeOutgoingEdge(nodeId: PublicKey, hops: Seq[Hop], ignore: Ignore): Ignore = {
hops.collectFirst {
case hop: ChannelHop if hop.nodeId == nodeId => ChannelDesc(hop.shortChannelId, hop.nodeId, hop.nextNodeId)
case hop: BlindedHop if hop.nodeId == nodeId => ChannelDesc(hop.dummyId, hop.nodeId, hop.nextNodeId)
// The error comes from inside the blinded route: this is a spec violation, errors should always come from the
// introduction node, so we definitely want to ignore this blinded route when this happens.
case hop: BlindedHop if hop.route.blindedNodeIds.contains(nodeId) => ChannelDesc(hop.dummyId, hop.nodeId, hop.nextNodeId)
} match {
case Some(faultyChannel) => ignore + faultyChannel
case Some(faultyEdge) => ignore + faultyEdge
case None => ignore
}
}
Expand All @@ -207,7 +211,7 @@ object PaymentFailure {
case _ => false
}
if (shouldIgnore) {
ignoreNodeOutgoingChannel(nodeId, hops, ignore)
ignoreNodeOutgoingEdge(nodeId, hops, ignore)
} else {
// We were using an outdated channel update, we should retry with the new one and nobody should be penalized.
ignore
Expand All @@ -217,10 +221,14 @@ object PaymentFailure {
ignore + nodeId
}
case RemoteFailure(_, hops, Sphinx.DecryptedFailurePacket(nodeId, _)) =>
ignoreNodeOutgoingChannel(nodeId, hops, ignore)
ignoreNodeOutgoingEdge(nodeId, hops, ignore)
case UnreadableRemoteFailure(_, hops) =>
// We don't know which node is sending garbage, let's blacklist all nodes except the one we are directly connected to and the final recipient.
val blacklist = hops.map(_.nextNodeId).drop(1).dropRight(1).toSet
// We don't know which node is sending garbage, let's blacklist all nodes except:
// - the one we are directly connected to: it would be too restrictive for retries
// - the final recipient: they have no incentive to send garbage since they want that payment
// - the introduction point of a blinded route: we don't want a node before the blinded path to force us to ignore that blinded path
// - the trampoline node: we don't want a node before the trampoline node to force us to ignore that trampoline node
val blacklist = hops.collect { case hop: ChannelHop => hop }.map(_.nextNodeId).drop(1).dropRight(1).toSet
ignore ++ blacklist
case LocalFailure(_, hops, _) => hops.headOption match {
case Some(hop: ChannelHop) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.eclair.channel.{CMD_ADD_HTLC, CMD_FAIL_HTLC, CannotExtractSharedSecret, Origin}
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.payment.send.Recipient
import fr.acinq.eclair.router.Router.Route
import fr.acinq.eclair.router.Router.{BlindedHop, ChannelHop, Route}
import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, PerHopPayload}
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Feature, Features, MilliSatoshi, ShortChannelId, UInt64, randomKey}
Expand Down Expand Up @@ -182,6 +182,8 @@ object IncomingPaymentPacket {
case payload if add.amountMsat < payload.paymentConstraints.minAmount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if add.cltvExpiry > payload.paymentConstraints.maxCltvExpiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if add.amountMsat < payload.amount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if add.cltvExpiry < payload.expiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload => Right(FinalPacket(add, payload))
}
}
Expand Down Expand Up @@ -231,8 +233,10 @@ object OutgoingPaymentPacket {

sealed trait OutgoingPaymentError extends Throwable
case class CannotCreateOnion(message: String) extends OutgoingPaymentError { override def getMessage: String = message }
case class CannotDecryptBlindedRoute(message: String) extends OutgoingPaymentError { override def getMessage: String = message }
case class InvalidRouteRecipient(expected: PublicKey, actual: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to $expected, got route to $actual" }
case class MissingTrampolineHop(trampolineNodeId: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to trampoline node $trampolineNodeId" }
case class MissingBlindedHop(introductionNodeIds: Set[PublicKey]) extends OutgoingPaymentError { override def getMessage: String = s"expected blinded route using one of the following introduction nodes: ${introductionNodeIds.mkString(", ")}" }
case object EmptyRoute extends OutgoingPaymentError { override def getMessage: String = "route cannot be empty" }

sealed trait Upstream
Expand Down Expand Up @@ -261,15 +265,41 @@ object OutgoingPaymentPacket {
}
}

private case class OutgoingPaymentWithChannel(shortChannelId: ShortChannelId, nextBlinding_opt: Option[PublicKey], payment: PaymentPayloads)

private def getOutgoingChannel(privateKey: PrivateKey, payment: PaymentPayloads, route: Route): Either[OutgoingPaymentError, OutgoingPaymentWithChannel] = {
route.hops.headOption match {
case Some(hop) => Right(OutgoingPaymentWithChannel(hop.shortChannelId, None, payment))
case None => route.finalHop_opt match {
case Some(hop: BlindedHop) =>
// We are the introduction node of the blinded route: we need to decrypt the first payload.
val firstBlinding = hop.route.introductionNode.blindingEphemeralKey
val firstEncryptedPayload = hop.route.introductionNode.encryptedPayload
RouteBlindingEncryptedDataCodecs.decode(privateKey, firstBlinding, firstEncryptedPayload) match {
case Left(e) => Left(CannotDecryptBlindedRoute(e.message))
case Right(decoded) =>
val tlvs = TlvStream[OnionPaymentPayloadTlv](OnionPaymentPayloadTlv.EncryptedRecipientData(firstEncryptedPayload), OnionPaymentPayloadTlv.BlindingPoint(firstBlinding))
IntermediatePayload.ChannelRelay.Blinded.validate(tlvs, decoded.tlvs, decoded.nextBlinding) match {
case Left(e) => Left(CannotDecryptBlindedRoute(e.failureMessage.message))
case Right(payload) =>
val payment1 = PaymentPayloads(payload.amountToForward(payment.amount), payload.outgoingCltv(payment.expiry), payment.payloads.tail)
Right(OutgoingPaymentWithChannel(payload.outgoingChannelId, Some(decoded.nextBlinding), payment1))
}
}
case _ => Left(EmptyRoute)
}
}
}

/** Build the command to add an HTLC for the given recipient using the provided route. */
def buildOutgoingPayment(replyTo: ActorRef, upstream: Upstream, paymentHash: ByteVector32, route: Route, recipient: Recipient): Either[OutgoingPaymentError, OutgoingPaymentPacket] = {
val outgoingChannel = route.hops.head.shortChannelId
def buildOutgoingPayment(replyTo: ActorRef, privateKey: PrivateKey, upstream: Upstream, paymentHash: ByteVector32, route: Route, recipient: Recipient): Either[OutgoingPaymentError, OutgoingPaymentPacket] = {
for {
payment <- recipient.buildPayloads(paymentHash, route)
onion <- buildOnion(PaymentOnionCodecs.paymentOnionPayloadLength, payment.payloads, paymentHash) // BOLT 2 requires that associatedData == paymentHash
paymentTmp <- recipient.buildPayloads(paymentHash, route)
outgoing <- getOutgoingChannel(privateKey, paymentTmp, route)
onion <- buildOnion(PaymentOnionCodecs.paymentOnionPayloadLength, outgoing.payment.payloads, paymentHash) // BOLT 2 requires that associatedData == paymentHash
} yield {
val cmd = CMD_ADD_HTLC(replyTo, payment.amount, paymentHash, payment.expiry, onion.packet, None, Origin.Hot(replyTo, upstream), commit = true)
OutgoingPaymentPacket(cmd, outgoingChannel, onion.sharedSecrets)
val cmd = CMD_ADD_HTLC(replyTo, outgoing.payment.amount, paymentHash, outgoing.payment.expiry, onion.packet, outgoing.nextBlinding_opt, Origin.Hot(replyTo, upstream), commit = true)
OutgoingPaymentPacket(cmd, outgoing.shortChannelId, onion.sharedSecrets)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
if (cfg.storeInDb && d.pending.isEmpty && d.failures.isEmpty) {
// In cases where we fail early (router error during the first attempt), the DB won't have an entry for that
// payment, which may be confusing for users.
val dummyPayment = OutgoingPayment(id, cfg.parentId, cfg.externalId, paymentHash, PaymentType.Standard, d.request.recipient.totalAmount, d.request.recipient.totalAmount, d.request.recipient.nodeId, TimestampMilli.now(), cfg.invoice, OutgoingPaymentStatus.Pending)
val dummyPayment = OutgoingPayment(id, cfg.parentId, cfg.externalId, paymentHash, cfg.paymentType, d.request.recipient.totalAmount, d.request.recipient.totalAmount, d.request.recipient.nodeId, TimestampMilli.now(), cfg.invoice, OutgoingPaymentStatus.Pending)
nodeParams.db.payments.addOutgoingPayment(dummyPayment)
nodeParams.db.payments.updateOutgoingPayment(PaymentFailed(id, paymentHash, failure :: Nil))
}
Expand Down
Loading